@phnx-labs/agents-cli 1.22.52 → 1.22.53
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 +146 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.js +2 -0
- package/dist/commands/go.js +2 -1
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions.js +30 -20
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/sync.js +44 -0
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +150 -50
- package/dist/lib/answer-router.js +2 -1
- 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/daemon/runner.js +10 -2
- package/dist/lib/device-config.js +3 -2
- package/dist/lib/devices/config-migration.js +147 -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/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +60 -30
- package/dist/lib/exec.js +65 -27
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- 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/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +7 -1
- 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 +1 -1
- package/dist/lib/session/db.js +39 -3
- package/dist/lib/session/discover.js +7 -12
- 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/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/worker-template.js +12 -7
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- 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/watchdog/runner.js +18 -4
- package/package.json +1 -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).
|
|
@@ -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';
|
|
@@ -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;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { addressabilityRecoveryHint } from './terminal/resolve.js';
|
|
1
2
|
import { injectTargetFromReplyRail } from './session/inject.js';
|
|
2
3
|
/**
|
|
3
4
|
* Match free-text answer against question options.
|
|
@@ -123,7 +124,7 @@ export function resolveAnswerRoute(input) {
|
|
|
123
124
|
return {
|
|
124
125
|
kind: 'refuse',
|
|
125
126
|
reason: 'Agent is parked on a question but has no addressable terminal (no tmux/iterm/pty rail). ' +
|
|
126
|
-
|
|
127
|
+
addressabilityRecoveryHint(session),
|
|
127
128
|
};
|
|
128
129
|
}
|
|
129
130
|
// Open block but agent is still looping between tool calls — mailbox is correct.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BrowserProfileConfig } from '../types.js';
|
|
1
2
|
import type { BrowserProfile, ProfileName } from './types.js';
|
|
2
3
|
export type { BrowserProfile } from './types.js';
|
|
3
4
|
export { declaringDevices, profileKind, profileRegistry, type ProfileDeclaration, } from './registry.js';
|
|
@@ -175,6 +176,23 @@ export declare function effectiveLocalPort(profile: BrowserProfile): number | un
|
|
|
175
176
|
* skip remote profiles in this scan.
|
|
176
177
|
*/
|
|
177
178
|
export declare function findFreeProfilePort(): Promise<number>;
|
|
179
|
+
export declare function hasSshEndpoint(endpoints: BrowserProfileConfig['endpoints']): boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
|
|
182
|
+
* into THIS device's doc during `agents sync`.
|
|
183
|
+
*
|
|
184
|
+
* Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
|
|
185
|
+
* host, so the profile is fungible by design — any box resolves it to the same
|
|
186
|
+
* browser, and a concurrent double-claim across machines is harmless. A
|
|
187
|
+
* local/`cdp://` profile has NO per-machine ownership signal: "that browser is
|
|
188
|
+
* installed here" is not "I hold this profile's credentialed session", so two
|
|
189
|
+
* boxes with the same common browser installed would each auto-claim the same
|
|
190
|
+
* tombstone on their first post-merge sync and flip it identity->fungible
|
|
191
|
+
* fleet-wide (the exact logged-out-browser failure this module exists to prevent
|
|
192
|
+
* — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
|
|
193
|
+
* `agents browser profiles claim`.
|
|
194
|
+
*/
|
|
195
|
+
export declare function shouldAutoClaimCentralProfile(config: BrowserProfileConfig): boolean;
|
|
178
196
|
/**
|
|
179
197
|
* Refuse a profile whose LOCAL port another profile already owns.
|
|
180
198
|
*
|
|
@@ -390,7 +390,7 @@ function validateRemoteBrowserBinary(binary) {
|
|
|
390
390
|
throw new Error(`Remote browser binary contains shell metacharacters: ${binary}`);
|
|
391
391
|
}
|
|
392
392
|
}
|
|
393
|
-
function hasSshEndpoint(endpoints) {
|
|
393
|
+
export function hasSshEndpoint(endpoints) {
|
|
394
394
|
const targets = Array.isArray(endpoints)
|
|
395
395
|
? endpoints
|
|
396
396
|
: Object.values(endpoints).map((preset) => preset.target);
|
|
@@ -403,6 +403,31 @@ function hasSshEndpoint(endpoints) {
|
|
|
403
403
|
}
|
|
404
404
|
});
|
|
405
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
|
|
408
|
+
* into THIS device's doc during `agents sync`.
|
|
409
|
+
*
|
|
410
|
+
* Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
|
|
411
|
+
* host, so the profile is fungible by design — any box resolves it to the same
|
|
412
|
+
* browser, and a concurrent double-claim across machines is harmless. A
|
|
413
|
+
* local/`cdp://` profile has NO per-machine ownership signal: "that browser is
|
|
414
|
+
* installed here" is not "I hold this profile's credentialed session", so two
|
|
415
|
+
* boxes with the same common browser installed would each auto-claim the same
|
|
416
|
+
* tombstone on their first post-merge sync and flip it identity->fungible
|
|
417
|
+
* fleet-wide (the exact logged-out-browser failure this module exists to prevent
|
|
418
|
+
* — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
|
|
419
|
+
* `agents browser profiles claim`.
|
|
420
|
+
*/
|
|
421
|
+
export function shouldAutoClaimCentralProfile(config) {
|
|
422
|
+
if (!hasSshEndpoint(config.endpoints))
|
|
423
|
+
return false;
|
|
424
|
+
return isProfileLaunchableHere({
|
|
425
|
+
name: '_',
|
|
426
|
+
browser: config.browser,
|
|
427
|
+
binary: config.binary,
|
|
428
|
+
endpoints: config.endpoints,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
406
431
|
/**
|
|
407
432
|
* Refuse a profile whose LOCAL port another profile already owns.
|
|
408
433
|
*
|
|
@@ -3,7 +3,15 @@ export interface ProfileDeclaration {
|
|
|
3
3
|
device: string;
|
|
4
4
|
config: BrowserProfileConfig;
|
|
5
5
|
}
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* The leftover central `browser:` tombstone from before per-device declarations.
|
|
8
|
+
* Empty when none remain — and `agents sync` now drains it automatically via
|
|
9
|
+
* {@link autoEvictCentralBrowserProfiles}, so on a synced box this is empty and
|
|
10
|
+
* {@link profileRegistry} is the single source of truth. It survives only to
|
|
11
|
+
* fold in migration and to explain an as-yet-unclaimed profile in a resolve
|
|
12
|
+
* error (a profile hostable on a peer that has not synced yet), never as a
|
|
13
|
+
* parallel store callers read for resolution.
|
|
14
|
+
*/
|
|
7
15
|
export declare function centralBrowserProfiles(): Record<string, BrowserProfileConfig>;
|
|
8
16
|
export interface CentralClaimResult {
|
|
9
17
|
claimed: string[];
|
|
@@ -12,22 +20,44 @@ export interface CentralClaimResult {
|
|
|
12
20
|
/**
|
|
13
21
|
* Fold leftover central `browser:` entries into THIS device's declaration file.
|
|
14
22
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* The EXPLICIT operator action (`agents browser profiles claim`), run on the
|
|
24
|
+
* machine that actually owns the browser. It is never called implicitly from a
|
|
25
|
+
* read, and that is the point: every device can read the central map, so an
|
|
26
|
+
* implicit claim races — whichever box reads first claims the name, {@link
|
|
27
|
+
* profileKind} then reports it `identity`, and the daemon tunnels to that box,
|
|
28
|
+
* which for a fleet-wide `cdp://localhost:*` profile is a logged-out headless
|
|
29
|
+
* browser wearing a credentialed browser's name. {@link autoEvictCentralBrowserProfiles}
|
|
30
|
+
* closes that race for the automatic path (host-gated + non-throwing on sync);
|
|
31
|
+
* this one is the manual, throw-on-conflict counterpart.
|
|
22
32
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Only profiles this machine can host are claimed; the rest stay central,
|
|
28
|
-
* undeclared, and fail loudly on resolve.
|
|
33
|
+
* `canHostHere` is supplied by the command layer so this module stays a leaf —
|
|
34
|
+
* it must not import `isProfileLaunchableHere` (that would cycle through
|
|
35
|
+
* chrome.ts → profiles.ts). Only profiles this machine can host are claimed; the
|
|
36
|
+
* rest stay central, undeclared, and fail loudly on resolve.
|
|
29
37
|
*/
|
|
30
38
|
export declare function migrateCentralBrowserProfiles(canHostHere: (config: BrowserProfileConfig) => boolean, name?: string): CentralClaimResult;
|
|
39
|
+
/**
|
|
40
|
+
* Automatic, self-draining counterpart to {@link migrateCentralBrowserProfiles}:
|
|
41
|
+
* fold every lingering central `browser:` profile THIS machine can host into its
|
|
42
|
+
* device doc and clear it from central, so the tombstone drains itself on
|
|
43
|
+
* `agents sync` with no manual `agents browser profiles claim`.
|
|
44
|
+
*
|
|
45
|
+
* Safe to call implicitly, unlike a claim at registry-read time (which races —
|
|
46
|
+
* see the `profileRegistry does not claim central declarations` guard), but the
|
|
47
|
+
* CALLER owns one ordering invariant: invoke it AFTER the sync's repo pull. Two
|
|
48
|
+
* boxes can both host the same profile (`canHostHere` is launchability, not
|
|
49
|
+
* ownership), so a box acting on a PRE-pull view of central could re-claim a
|
|
50
|
+
* profile a peer already drained and pushed — both writes land in different
|
|
51
|
+
* device files with no git conflict, and the profile flips identity->fungible.
|
|
52
|
+
* Running post-pull means central already reflects peers' drains, so the tombstone
|
|
53
|
+
* is gone locally before this box looks. The selection is also computed INSIDE
|
|
54
|
+
* the meta lock, and the path never throws — an unhostable profile or one that
|
|
55
|
+
* conflicts with an existing local declaration is left central for an explicit
|
|
56
|
+
* claim rather than wedging the sync. After it runs, {@link profileRegistry} is
|
|
57
|
+
* the single source of truth: a claimed profile lives in the device doc alone,
|
|
58
|
+
* never double-counted across the two stores.
|
|
59
|
+
*/
|
|
60
|
+
export declare function autoEvictCentralBrowserProfiles(canHostHere: (config: BrowserProfileConfig) => boolean): CentralClaimResult;
|
|
31
61
|
/**
|
|
32
62
|
* Every profile any device declares, keyed by name to all declaring devices.
|
|
33
63
|
* Reads every `devices/<name>/agents.yaml`; declarations never overwrite.
|