@phnx-labs/agents-cli 1.22.51 → 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 +238 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.js +118 -56
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +8 -4
- package/dist/commands/detach.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +16 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +65 -6
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions-resume.d.ts +1 -0
- package/dist/commands/sessions-resume.js +13 -2
- package/dist/commands/sessions-stop.js +1 -1
- package/dist/commands/sessions.d.ts +23 -13
- package/dist/commands/sessions.js +69 -39
- package/dist/commands/setup-browser.d.ts +5 -2
- package/dist/commands/setup-browser.js +14 -29
- package/dist/commands/setup-preferences.d.ts +22 -3
- package/dist/commands/setup-preferences.js +25 -8
- package/dist/commands/share.js +12 -8
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +102 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- 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/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +57 -17
- package/dist/lib/browser/profiles.js +77 -53
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/browser/runtime-state.d.ts +4 -2
- package/dist/lib/browser/runtime-state.js +4 -2
- package/dist/lib/browser/service.js +4 -3
- package/dist/lib/channels/owner-forward.d.ts +88 -0
- package/dist/lib/channels/owner-forward.js +116 -0
- package/dist/lib/channels/owner-sink.js +7 -0
- 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 +66 -28
- package/dist/lib/exec.js +71 -26
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- package/dist/lib/feed-broadcast.js +15 -1
- package/dist/lib/git.d.ts +93 -0
- package/dist/lib/git.js +232 -0
- 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/monitors/remote.d.ts +18 -1
- package/dist/lib/monitors/remote.js +15 -2
- package/dist/lib/notify.d.ts +7 -0
- package/dist/lib/notify.js +15 -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/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -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/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/publish.d.ts +8 -11
- package/dist/lib/share/publish.js +16 -20
- package/dist/lib/share/worker-template.js +104 -12
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +29 -0
- 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
|
@@ -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.
|
|
@@ -12,6 +12,50 @@ export declare function getSocketPath(): string;
|
|
|
12
12
|
/** Is the daemon reachable? A real connect probe on every platform — a socket
|
|
13
13
|
* file existing on disk is not proof a daemon is listening on it. */
|
|
14
14
|
export declare function isDaemonReachable(): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Wait until the browser daemon is genuinely reachable, or throw.
|
|
17
|
+
*
|
|
18
|
+
* Re-probes across the whole window rather than latching on the first accept, so
|
|
19
|
+
* it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
|
|
20
|
+
* just resets the consecutive-accept counter, and the loop keeps going until the
|
|
21
|
+
* daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
|
|
22
|
+
* that never comes up throws a message naming the endpoint and the budget, never
|
|
23
|
+
* a silent hang.
|
|
24
|
+
*/
|
|
25
|
+
export declare function waitForSocket(_socketPath: string, timeoutMs?: number): Promise<void>;
|
|
26
|
+
/** Outcome of {@link resetBrowserDaemon} — what the wedge-recovery actually did. */
|
|
27
|
+
export interface BrowserDaemonResetResult {
|
|
28
|
+
/** The daemon was reachable before the reset and a stop was issued. */
|
|
29
|
+
wasRunning: boolean;
|
|
30
|
+
/** A leftover `browser.sock` file was unlinked (POSIX only). */
|
|
31
|
+
socketCleared: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Clear a wedged browser daemon so a subsequent `start` comes up clean
|
|
35
|
+
* (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
|
|
36
|
+
* `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
|
|
37
|
+
* endpoint to stop accepting, then unlinks any stale `browser.sock` a
|
|
38
|
+
* hard-crashed daemon left behind — the file a fresh `start` would otherwise
|
|
39
|
+
* `unlink` blindly, racing whatever still holds it.
|
|
40
|
+
*
|
|
41
|
+
* Fails loud: if the endpoint is *still* reachable after the quiesce window, a
|
|
42
|
+
* live server is holding it and clearing the socket under it would orphan two
|
|
43
|
+
* servers on one path, so we throw rather than pretend the reset worked. The
|
|
44
|
+
* daemon auto-restarts on the next browser command.
|
|
45
|
+
*/
|
|
46
|
+
export declare function resetBrowserDaemon(): Promise<BrowserDaemonResetResult>;
|
|
47
|
+
/**
|
|
48
|
+
* Remove a leftover browser socket FILE, but only when nothing is listening on
|
|
49
|
+
* it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
|
|
50
|
+
* window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
|
|
51
|
+
* deciding the endpoint was unreachable and this unlink, a concurrent
|
|
52
|
+
* `browser start` could bind a NEW listener on the same path; an unconditional
|
|
53
|
+
* unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
|
|
54
|
+
* this function exists to prevent. A listener seen here means the wedge is already
|
|
55
|
+
* resolved (a fresh daemon owns the path), so its socket is left intact. Returns
|
|
56
|
+
* true only when a genuinely dead file was removed.
|
|
57
|
+
*/
|
|
58
|
+
export declare function clearDeadSocketFile(endpoint: string, socketPath: string): Promise<boolean>;
|
|
15
59
|
/**
|
|
16
60
|
* One long-lived connection to the existing browser daemon. Requests are
|
|
17
61
|
* serialized so the daemon's newline-delimited responses always map to the
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -69,6 +69,29 @@ const SOCKET_NAME = 'browser.sock';
|
|
|
69
69
|
* coming near the grace window.
|
|
70
70
|
*/
|
|
71
71
|
const IPC_CLOSE_TIMEOUT_MS = 1_500;
|
|
72
|
+
/**
|
|
73
|
+
* How long {@link waitForSocket} waits for the browser daemon to come up before
|
|
74
|
+
* failing loud (PHNX-3289).
|
|
75
|
+
*
|
|
76
|
+
* The old flat 6s ceiling was the wedge: the shared daemon's browser IPC server
|
|
77
|
+
* restarts (version reconcile, supervisor restart, a hard-crashed predecessor's
|
|
78
|
+
* successor claiming the socket), and a client that started its wait *during*
|
|
79
|
+
* one of those restart windows could burn the whole 6s and throw
|
|
80
|
+
* `Timeout waiting for browser daemon socket` on an otherwise-healthy daemon. A
|
|
81
|
+
* browser start/navigate then failed intermittently and self-healed on the next
|
|
82
|
+
* try. 15s comfortably spans a restart; the stable-probe requirement below is
|
|
83
|
+
* what keeps a socket that "appears and is immediately destroyed" (#556) from
|
|
84
|
+
* being mistaken for a ready daemon.
|
|
85
|
+
*/
|
|
86
|
+
const SOCKET_WAIT_TIMEOUT_MS = 15_000;
|
|
87
|
+
/**
|
|
88
|
+
* Consecutive successful probes required before {@link waitForSocket} declares
|
|
89
|
+
* the daemon ready. A single accept can land in the sliver between a restarting
|
|
90
|
+
* server binding and tearing back down; requiring two accepts ~100ms apart means
|
|
91
|
+
* we only return once the daemon is *staying* up, so the caller's real request
|
|
92
|
+
* doesn't race a restart it happened to probe mid-flight.
|
|
93
|
+
*/
|
|
94
|
+
const SOCKET_WAIT_STABLE_PROBES = 2;
|
|
72
95
|
export class BrowserDaemonNotRunningError extends Error {
|
|
73
96
|
constructor() {
|
|
74
97
|
super(formatBrowserDaemonNotRunningError());
|
|
@@ -78,8 +101,8 @@ export class BrowserDaemonNotRunningError extends Error {
|
|
|
78
101
|
export function formatBrowserDaemonNotRunningError() {
|
|
79
102
|
return [
|
|
80
103
|
'Browser daemon not running.',
|
|
81
|
-
'Start it with: agents browser start (
|
|
82
|
-
'
|
|
104
|
+
'Start it with: agents browser start (uses this machine\'s configured default browser)',
|
|
105
|
+
'Pick / pin a profile: agents browser use <name> (or: agents browser start --profile <name>)',
|
|
83
106
|
'List profiles: agents browser profiles list',
|
|
84
107
|
].join('\n');
|
|
85
108
|
}
|
|
@@ -117,15 +140,104 @@ function probeDaemon(endpoint, timeoutMs = 500) {
|
|
|
117
140
|
export async function isDaemonReachable() {
|
|
118
141
|
return probeDaemon(getIpcEndpoint());
|
|
119
142
|
}
|
|
120
|
-
|
|
143
|
+
/**
|
|
144
|
+
* Wait until the browser daemon is genuinely reachable, or throw.
|
|
145
|
+
*
|
|
146
|
+
* Re-probes across the whole window rather than latching on the first accept, so
|
|
147
|
+
* it survives an IPC-server restart that happens mid-wait (PHNX-3289): a restart
|
|
148
|
+
* just resets the consecutive-accept counter, and the loop keeps going until the
|
|
149
|
+
* daemon is *stably* up or the deadline passes. Bounded and fail-loud — a daemon
|
|
150
|
+
* that never comes up throws a message naming the endpoint and the budget, never
|
|
151
|
+
* a silent hang.
|
|
152
|
+
*/
|
|
153
|
+
export async function waitForSocket(_socketPath, timeoutMs = SOCKET_WAIT_TIMEOUT_MS) {
|
|
121
154
|
const endpoint = getIpcEndpoint();
|
|
122
155
|
const deadline = Date.now() + timeoutMs;
|
|
156
|
+
let consecutive = 0;
|
|
123
157
|
while (Date.now() < deadline) {
|
|
124
|
-
if (await probeDaemon(endpoint))
|
|
125
|
-
|
|
158
|
+
if (await probeDaemon(endpoint)) {
|
|
159
|
+
consecutive += 1;
|
|
160
|
+
if (consecutive >= SOCKET_WAIT_STABLE_PROBES)
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// A dropped probe means a restart (or the daemon isn't up yet) — start the
|
|
165
|
+
// stability count over rather than counting accepts from before the gap.
|
|
166
|
+
consecutive = 0;
|
|
167
|
+
}
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`Timeout waiting for browser daemon socket after ${Math.round(timeoutMs / 1000)}s (${endpoint}).`);
|
|
171
|
+
}
|
|
172
|
+
/** How long {@link resetBrowserDaemon} waits for the endpoint to go quiet after
|
|
173
|
+
* signalling a stop before it clears the socket and re-checks. Bounded so the
|
|
174
|
+
* command fails loud instead of hanging on a daemon that will not die. */
|
|
175
|
+
const DAEMON_RESET_QUIESCE_MS = 5_000;
|
|
176
|
+
/**
|
|
177
|
+
* Clear a wedged browser daemon so a subsequent `start` comes up clean
|
|
178
|
+
* (PHNX-3289). Stops the shared daemon (the same `stopDaemon` path
|
|
179
|
+
* `reconcileDaemonVersion` uses for a stale-version restart), waits for the IPC
|
|
180
|
+
* endpoint to stop accepting, then unlinks any stale `browser.sock` a
|
|
181
|
+
* hard-crashed daemon left behind — the file a fresh `start` would otherwise
|
|
182
|
+
* `unlink` blindly, racing whatever still holds it.
|
|
183
|
+
*
|
|
184
|
+
* Fails loud: if the endpoint is *still* reachable after the quiesce window, a
|
|
185
|
+
* live server is holding it and clearing the socket under it would orphan two
|
|
186
|
+
* servers on one path, so we throw rather than pretend the reset worked. The
|
|
187
|
+
* daemon auto-restarts on the next browser command.
|
|
188
|
+
*/
|
|
189
|
+
export async function resetBrowserDaemon() {
|
|
190
|
+
const endpoint = getIpcEndpoint();
|
|
191
|
+
const wasRunning = await probeDaemon(endpoint);
|
|
192
|
+
stopDaemon();
|
|
193
|
+
// Wait for the listener to actually release the endpoint. We must decide
|
|
194
|
+
// reachability BEFORE touching the socket file: unlinking a path out from
|
|
195
|
+
// under a live server makes new connects ENOENT (so it would *look* cleared)
|
|
196
|
+
// while the server keeps running — the two-servers orphan the eviction
|
|
197
|
+
// protocol exists to prevent. So a still-reachable endpoint fails loud here,
|
|
198
|
+
// and only a genuinely dead one gets its stale file removed below.
|
|
199
|
+
const deadline = Date.now() + DAEMON_RESET_QUIESCE_MS;
|
|
200
|
+
let reachable = wasRunning;
|
|
201
|
+
while (reachable && Date.now() < deadline) {
|
|
126
202
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
203
|
+
reachable = await probeDaemon(endpoint);
|
|
204
|
+
}
|
|
205
|
+
if (reachable) {
|
|
206
|
+
throw new Error(actionable('Browser daemon is still reachable after stop — a live server is holding the socket.', `Endpoint: ${endpoint}`, 'Next: agents daemon status (find and stop the process holding it)'));
|
|
207
|
+
}
|
|
208
|
+
// Nothing is listening now — clear the leftover socket file a hard-crashed
|
|
209
|
+
// daemon left behind, so the next `start` binds clean instead of unlinking it
|
|
210
|
+
// blindly. (Named pipes vanish with their owning process, so Windows has no
|
|
211
|
+
// stale file to clear.)
|
|
212
|
+
const socketCleared = IS_WINDOWS ? false : await clearDeadSocketFile(endpoint, getSocketPath());
|
|
213
|
+
return { wasRunning, socketCleared };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Remove a leftover browser socket FILE, but only when nothing is listening on
|
|
217
|
+
* it — re-probing liveness IMMEDIATELY before the unlink to close the TOCTOU
|
|
218
|
+
* window (PHNX-3289 review). Between {@link resetBrowserDaemon}'s quiesce loop
|
|
219
|
+
* deciding the endpoint was unreachable and this unlink, a concurrent
|
|
220
|
+
* `browser start` could bind a NEW listener on the same path; an unconditional
|
|
221
|
+
* unlink would then delete a LIVE daemon's socket — the exact two-servers orphan
|
|
222
|
+
* this function exists to prevent. A listener seen here means the wedge is already
|
|
223
|
+
* resolved (a fresh daemon owns the path), so its socket is left intact. Returns
|
|
224
|
+
* true only when a genuinely dead file was removed.
|
|
225
|
+
*/
|
|
226
|
+
export async function clearDeadSocketFile(endpoint, socketPath) {
|
|
227
|
+
if (!fs.existsSync(socketPath))
|
|
228
|
+
return false;
|
|
229
|
+
// A listener bound again since the quiesce loop → do not touch its socket.
|
|
230
|
+
if (await probeDaemon(endpoint))
|
|
231
|
+
return false;
|
|
232
|
+
try {
|
|
233
|
+
fs.unlinkSync(socketPath);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Raced with a fresh start that already claimed it — the goal (no stale
|
|
238
|
+
// socket) still holds, so report cleared only when it is genuinely gone.
|
|
239
|
+
return !fs.existsSync(socketPath);
|
|
127
240
|
}
|
|
128
|
-
throw new Error('Timeout waiting for browser daemon socket');
|
|
129
241
|
}
|
|
130
242
|
/**
|
|
131
243
|
* One long-lived connection to the existing browser daemon. Requests are
|
|
@@ -865,7 +977,7 @@ async function reconcileDaemonVersion(socketPath) {
|
|
|
865
977
|
stopDaemon();
|
|
866
978
|
startDaemon();
|
|
867
979
|
if (!(await isDaemonReachable())) {
|
|
868
|
-
await waitForSocket(socketPath
|
|
980
|
+
await waitForSocket(socketPath);
|
|
869
981
|
}
|
|
870
982
|
await new Promise((r) => setTimeout(r, 300));
|
|
871
983
|
}
|
|
@@ -968,7 +1080,7 @@ async function prepareIPC(action, opts) {
|
|
|
968
1080
|
}
|
|
969
1081
|
startDaemon();
|
|
970
1082
|
if (!(await isDaemonReachable())) {
|
|
971
|
-
await waitForSocket(socketPath
|
|
1083
|
+
await waitForSocket(socketPath);
|
|
972
1084
|
}
|
|
973
1085
|
if (!(await isDaemonReachable())) {
|
|
974
1086
|
throw new Error(actionable('Failed to start browser daemon.', `Log: ${getDaemonLogPath()}`, 'Next: agents doctor (checks for a second agents-cli install)'));
|
|
@@ -1,15 +1,19 @@
|
|
|
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';
|
|
4
5
|
/**
|
|
5
|
-
* Name of the profile
|
|
6
|
+
* Name of the profile the setup wizards pin as this machine's default browser
|
|
7
|
+
* (`agents setup`, `agents setup browser`). Older builds also auto-created it
|
|
8
|
+
* silently on the first `agents browser start`; PHNX-3296 removed that — see
|
|
9
|
+
* {@link ensureDefaultBrowserProfile}.
|
|
6
10
|
*
|
|
7
11
|
* It is `auto-chrome`, NOT `default`, since RUSH-2709: `default` used to be
|
|
8
12
|
* both this concrete profile AND the alias meaning "whatever profile the user
|
|
9
|
-
* configured", so `--profile default` landed on a literal
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* configured", so `--profile default` landed on a literal Chrome on one command
|
|
14
|
+
* and on the user's configured Comet on another. The alias now lives alone in
|
|
15
|
+
* {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one place
|
|
16
|
+
* ({@link resolveProfileRef}).
|
|
13
17
|
*/
|
|
14
18
|
export declare const DEFAULT_BROWSER_PROFILE_NAME = "auto-chrome";
|
|
15
19
|
/**
|
|
@@ -106,33 +110,52 @@ export declare function resolveProfileRef(ref?: string): Promise<string | undefi
|
|
|
106
110
|
* no profile of that name) goes through {@link ensureDefaultBrowserProfile} —
|
|
107
111
|
* which additionally verifies the resolved default can launch on THIS machine.
|
|
108
112
|
* An undeclared configured default is an error. A declared default whose
|
|
109
|
-
* browser isn't installed here warns and falls through to
|
|
113
|
+
* browser isn't installed here warns and falls through to an existing profile,
|
|
114
|
+
* else the actionable throw below.
|
|
110
115
|
*
|
|
111
116
|
* `start` is the only command that launches a browser, so it is the only one
|
|
112
117
|
* that may do those things; routing a filter-only command through this would
|
|
113
|
-
* warn about
|
|
118
|
+
* warn about config the user never asked it to touch.
|
|
114
119
|
*
|
|
115
|
-
* Throws when the configured default is
|
|
116
|
-
*
|
|
120
|
+
* Throws ({@link noDefaultBrowserError}) when the configured default is
|
|
121
|
+
* undeclared, or when no profile exists that can launch here.
|
|
117
122
|
*/
|
|
118
123
|
export declare function resolveProfileRefForStart(ref?: string): Promise<string>;
|
|
124
|
+
/**
|
|
125
|
+
* The error a bare `agents browser start` raises when this machine has no
|
|
126
|
+
* launchable default browser. Its own function so the wording — the one thing
|
|
127
|
+
* the user reads when a browser won't start — stays in one place and is
|
|
128
|
+
* testable without spawning anything.
|
|
129
|
+
*
|
|
130
|
+
* Since PHNX-3296 this is a hard stop, NOT a silent auto-create. The old
|
|
131
|
+
* behavior probed the installed Chromium-family browsers and minted a
|
|
132
|
+
* logged-out `auto-chrome` profile on the spot; agents then drove a signed-out
|
|
133
|
+
* Chrome that popped up on the user's Mac unbidden. Which browser agents drive
|
|
134
|
+
* is a choice the user makes once, in `agents setup` — never one this code
|
|
135
|
+
* makes for them.
|
|
136
|
+
*/
|
|
137
|
+
export declare function noDefaultBrowserError(): Error;
|
|
119
138
|
/**
|
|
120
139
|
* Resolve the profile a bare `agents browser start` uses.
|
|
121
140
|
*
|
|
122
141
|
* Order: (1) the device-local configured default (`agents browser use <name>`)
|
|
123
142
|
* when it names a profile that exists and can launch here; (2) an existing
|
|
124
|
-
* auto-detected profile
|
|
125
|
-
*
|
|
126
|
-
*
|
|
143
|
+
* auto-detected profile (`auto-chrome`, or a legacy `default`) that can launch
|
|
144
|
+
* here. When neither resolves, THROW ({@link noDefaultBrowserError}) rather than
|
|
145
|
+
* detect-and-create — see that function for why (PHNX-3296).
|
|
127
146
|
*
|
|
128
147
|
* Two failure modes at the configured-default step are not the same:
|
|
129
148
|
* - No device declares the name (including a leftover central `browser:`
|
|
130
|
-
* entry that was never claimed) → throw.
|
|
131
|
-
* hand the agent a logged-out browser while `browser.profile` still
|
|
132
|
-
* the credentialed one.
|
|
149
|
+
* entry that was never claimed) → throw. Falling back to a minted profile
|
|
150
|
+
* would hand the agent a logged-out browser while `browser.profile` still
|
|
151
|
+
* names the credentialed one.
|
|
133
152
|
* - The name is declared, but its browser/binary is not installed HERE →
|
|
134
|
-
* warn and fall through
|
|
135
|
-
* missing
|
|
153
|
+
* warn and fall through to an existing profile, else the actionable throw.
|
|
154
|
+
* That is a missing binary on this box, not a missing identity.
|
|
155
|
+
*
|
|
156
|
+
* This RECOGNIZES a pre-existing `auto-chrome`/legacy `default` so installs that
|
|
157
|
+
* already carry one keep resolving it (and its running browser + runtime dirs),
|
|
158
|
+
* but it never CREATES one.
|
|
136
159
|
*/
|
|
137
160
|
export declare function ensureDefaultBrowserProfile(): Promise<BrowserProfile>;
|
|
138
161
|
/**
|
|
@@ -153,6 +176,23 @@ export declare function effectiveLocalPort(profile: BrowserProfile): number | un
|
|
|
153
176
|
* skip remote profiles in this scan.
|
|
154
177
|
*/
|
|
155
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;
|
|
156
196
|
/**
|
|
157
197
|
* Refuse a profile whose LOCAL port another profile already owns.
|
|
158
198
|
*
|