@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
|
@@ -4,18 +4,20 @@ import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, readMeta, updateMeta,
|
|
|
4
4
|
import { getConfigValue } from '../device-config.js';
|
|
5
5
|
import { machineId } from '../machine-id.js';
|
|
6
6
|
import { declaringDevices, profileRegistry, } from './registry.js';
|
|
7
|
-
import { findBrowserPath,
|
|
8
|
-
import { DEFAULT_VIEWPORT } from './devices.js';
|
|
7
|
+
import { findBrowserPath, isPortInUse } from './chrome.js';
|
|
9
8
|
export { declaringDevices, profileKind, profileRegistry, } from './registry.js';
|
|
10
9
|
/**
|
|
11
|
-
* Name of the profile
|
|
10
|
+
* Name of the profile the setup wizards pin as this machine's default browser
|
|
11
|
+
* (`agents setup`, `agents setup browser`). Older builds also auto-created it
|
|
12
|
+
* silently on the first `agents browser start`; PHNX-3296 removed that — see
|
|
13
|
+
* {@link ensureDefaultBrowserProfile}.
|
|
12
14
|
*
|
|
13
15
|
* It is `auto-chrome`, NOT `default`, since RUSH-2709: `default` used to be
|
|
14
16
|
* both this concrete profile AND the alias meaning "whatever profile the user
|
|
15
|
-
* configured", so `--profile default` landed on a literal
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* configured", so `--profile default` landed on a literal Chrome on one command
|
|
18
|
+
* and on the user's configured Comet on another. The alias now lives alone in
|
|
19
|
+
* {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one place
|
|
20
|
+
* ({@link resolveProfileRef}).
|
|
19
21
|
*/
|
|
20
22
|
export const DEFAULT_BROWSER_PROFILE_NAME = 'auto-chrome';
|
|
21
23
|
/**
|
|
@@ -221,14 +223,15 @@ export async function resolveProfileRef(ref) {
|
|
|
221
223
|
* no profile of that name) goes through {@link ensureDefaultBrowserProfile} —
|
|
222
224
|
* which additionally verifies the resolved default can launch on THIS machine.
|
|
223
225
|
* An undeclared configured default is an error. A declared default whose
|
|
224
|
-
* browser isn't installed here warns and falls through to
|
|
226
|
+
* browser isn't installed here warns and falls through to an existing profile,
|
|
227
|
+
* else the actionable throw below.
|
|
225
228
|
*
|
|
226
229
|
* `start` is the only command that launches a browser, so it is the only one
|
|
227
230
|
* that may do those things; routing a filter-only command through this would
|
|
228
|
-
* warn about
|
|
231
|
+
* warn about config the user never asked it to touch.
|
|
229
232
|
*
|
|
230
|
-
* Throws when the configured default is
|
|
231
|
-
*
|
|
233
|
+
* Throws ({@link noDefaultBrowserError}) when the configured default is
|
|
234
|
+
* undeclared, or when no profile exists that can launch here.
|
|
232
235
|
*/
|
|
233
236
|
export async function resolveProfileRefForStart(ref) {
|
|
234
237
|
if (ref && ref !== DEFAULT_PROFILE_ALIAS)
|
|
@@ -238,23 +241,45 @@ export async function resolveProfileRefForStart(ref) {
|
|
|
238
241
|
return ref;
|
|
239
242
|
return (await ensureDefaultBrowserProfile()).name;
|
|
240
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* The error a bare `agents browser start` raises when this machine has no
|
|
246
|
+
* launchable default browser. Its own function so the wording — the one thing
|
|
247
|
+
* the user reads when a browser won't start — stays in one place and is
|
|
248
|
+
* testable without spawning anything.
|
|
249
|
+
*
|
|
250
|
+
* Since PHNX-3296 this is a hard stop, NOT a silent auto-create. The old
|
|
251
|
+
* behavior probed the installed Chromium-family browsers and minted a
|
|
252
|
+
* logged-out `auto-chrome` profile on the spot; agents then drove a signed-out
|
|
253
|
+
* Chrome that popped up on the user's Mac unbidden. Which browser agents drive
|
|
254
|
+
* is a choice the user makes once, in `agents setup` — never one this code
|
|
255
|
+
* makes for them.
|
|
256
|
+
*/
|
|
257
|
+
export function noDefaultBrowserError() {
|
|
258
|
+
return new Error('No default browser is configured on this machine. ' +
|
|
259
|
+
'Run `agents setup` (or `agents browser use <name>`) to pick the browser agents should drive. ' +
|
|
260
|
+
'If this is a headless worker, use the fleet hub instead: `agents config set browser.device <host>`.');
|
|
261
|
+
}
|
|
241
262
|
/**
|
|
242
263
|
* Resolve the profile a bare `agents browser start` uses.
|
|
243
264
|
*
|
|
244
265
|
* Order: (1) the device-local configured default (`agents browser use <name>`)
|
|
245
266
|
* when it names a profile that exists and can launch here; (2) an existing
|
|
246
|
-
* auto-detected profile
|
|
247
|
-
*
|
|
248
|
-
*
|
|
267
|
+
* auto-detected profile (`auto-chrome`, or a legacy `default`) that can launch
|
|
268
|
+
* here. When neither resolves, THROW ({@link noDefaultBrowserError}) rather than
|
|
269
|
+
* detect-and-create — see that function for why (PHNX-3296).
|
|
249
270
|
*
|
|
250
271
|
* Two failure modes at the configured-default step are not the same:
|
|
251
272
|
* - No device declares the name (including a leftover central `browser:`
|
|
252
|
-
* entry that was never claimed) → throw.
|
|
253
|
-
* hand the agent a logged-out browser while `browser.profile` still
|
|
254
|
-
* the credentialed one.
|
|
273
|
+
* entry that was never claimed) → throw. Falling back to a minted profile
|
|
274
|
+
* would hand the agent a logged-out browser while `browser.profile` still
|
|
275
|
+
* names the credentialed one.
|
|
255
276
|
* - The name is declared, but its browser/binary is not installed HERE →
|
|
256
|
-
* warn and fall through
|
|
257
|
-
* missing
|
|
277
|
+
* warn and fall through to an existing profile, else the actionable throw.
|
|
278
|
+
* That is a missing binary on this box, not a missing identity.
|
|
279
|
+
*
|
|
280
|
+
* This RECOGNIZES a pre-existing `auto-chrome`/legacy `default` so installs that
|
|
281
|
+
* already carry one keep resolving it (and its running browser + runtime dirs),
|
|
282
|
+
* but it never CREATES one.
|
|
258
283
|
*/
|
|
259
284
|
export async function ensureDefaultBrowserProfile() {
|
|
260
285
|
const configured = getConfiguredDefaultProfileName();
|
|
@@ -275,7 +300,7 @@ export async function ensureDefaultBrowserProfile() {
|
|
|
275
300
|
`Or unset the default with: agents browser use --unset`);
|
|
276
301
|
}
|
|
277
302
|
console.warn(`warning: configured default browser profile "${configured}" can't launch on this ` +
|
|
278
|
-
`machine (its browser/binary isn't installed here)
|
|
303
|
+
`machine (its browser/binary isn't installed here). ` +
|
|
279
304
|
`Fix with: agents browser use <name> (or --unset)`);
|
|
280
305
|
}
|
|
281
306
|
// Prefer whichever auto-detected profile this machine already carries: the
|
|
@@ -285,38 +310,12 @@ export async function ensureDefaultBrowserProfile() {
|
|
|
285
310
|
const existing = await getAutoDetectedProfile();
|
|
286
311
|
if (existing && isProfileLaunchableHere(existing))
|
|
287
312
|
return existing;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
'Chrome DevTools Protocol, which they don\'t implement.');
|
|
295
|
-
}
|
|
296
|
-
const freePort = await findFreeProfilePort();
|
|
297
|
-
const profile = {
|
|
298
|
-
// Regenerate under the name that is already on disk when there is one, so a
|
|
299
|
-
// stale legacy `default` is repaired in place instead of leaving the user
|
|
300
|
-
// with two auto-detected profiles.
|
|
301
|
-
name: existing?.name ?? DEFAULT_BROWSER_PROFILE_NAME,
|
|
302
|
-
description: `Auto-detected ${detected.browserType} profile`,
|
|
303
|
-
browser: detected.browserType,
|
|
304
|
-
binary: detected.binary,
|
|
305
|
-
endpoints: [`cdp://127.0.0.1:${freePort}`],
|
|
306
|
-
viewport: {
|
|
307
|
-
width: DEFAULT_VIEWPORT.width,
|
|
308
|
-
height: DEFAULT_VIEWPORT.height,
|
|
309
|
-
},
|
|
310
|
-
};
|
|
311
|
-
// A stale `default` (auto-created on another OS, unlaunchable here) is
|
|
312
|
-
// regenerated in place; otherwise this is the first-run create.
|
|
313
|
-
if (existing) {
|
|
314
|
-
await updateProfile(profile);
|
|
315
|
-
}
|
|
316
|
-
else {
|
|
317
|
-
await createProfile(profile);
|
|
318
|
-
}
|
|
319
|
-
return profile;
|
|
313
|
+
// No configured default resolves and no existing profile launches here. We
|
|
314
|
+
// used to auto-detect the first installed browser and silently mint (or
|
|
315
|
+
// regenerate) an `auto-chrome` profile at this point — that is exactly the
|
|
316
|
+
// logged-out-Chrome-on-your-Mac bug PHNX-3296 removed. Stop and tell the user
|
|
317
|
+
// how to choose a browser instead.
|
|
318
|
+
throw noDefaultBrowserError();
|
|
320
319
|
}
|
|
321
320
|
/**
|
|
322
321
|
* Compute the LOCAL port a profile will occupy at runtime:
|
|
@@ -391,7 +390,7 @@ function validateRemoteBrowserBinary(binary) {
|
|
|
391
390
|
throw new Error(`Remote browser binary contains shell metacharacters: ${binary}`);
|
|
392
391
|
}
|
|
393
392
|
}
|
|
394
|
-
function hasSshEndpoint(endpoints) {
|
|
393
|
+
export function hasSshEndpoint(endpoints) {
|
|
395
394
|
const targets = Array.isArray(endpoints)
|
|
396
395
|
? endpoints
|
|
397
396
|
: Object.values(endpoints).map((preset) => preset.target);
|
|
@@ -404,6 +403,31 @@ function hasSshEndpoint(endpoints) {
|
|
|
404
403
|
}
|
|
405
404
|
});
|
|
406
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
|
+
}
|
|
407
431
|
/**
|
|
408
432
|
* Refuse a profile whose LOCAL port another profile already owns.
|
|
409
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.
|
|
@@ -2,34 +2,108 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
4
|
import * as yaml from 'yaml';
|
|
5
|
-
import { getUserAgentsDir, readMeta, updateMeta } from '../state.js';
|
|
5
|
+
import { getUserAgentsDir, readMeta, updateMeta, withMetaLock, writeMetaUnlocked } from '../state.js';
|
|
6
6
|
function isRecord(value) {
|
|
7
7
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
8
8
|
}
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* The leftover central `browser:` tombstone from before per-device declarations.
|
|
11
|
+
* Empty when none remain — and `agents sync` now drains it automatically via
|
|
12
|
+
* {@link autoEvictCentralBrowserProfiles}, so on a synced box this is empty and
|
|
13
|
+
* {@link profileRegistry} is the single source of truth. It survives only to
|
|
14
|
+
* fold in migration and to explain an as-yet-unclaimed profile in a resolve
|
|
15
|
+
* error (a profile hostable on a peer that has not synced yet), never as a
|
|
16
|
+
* parallel store callers read for resolution.
|
|
17
|
+
*/
|
|
10
18
|
export function centralBrowserProfiles() {
|
|
11
19
|
const central = readMeta().browser;
|
|
12
20
|
if (!central || Object.keys(central).length === 0)
|
|
13
21
|
return {};
|
|
14
22
|
return { ...central };
|
|
15
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Pick the central `browser:` entries this machine may fold into its device doc.
|
|
26
|
+
*
|
|
27
|
+
* A profile is claimable only when `canHostHere` accepts it — the endpoint or
|
|
28
|
+
* binary resolves to THIS machine — so a `cdp://localhost:*` profile owned by a
|
|
29
|
+
* peer is never claimed here (the logged-out-headless-browser bug this module
|
|
30
|
+
* exists to prevent). A name already declared locally with an IDENTICAL config
|
|
31
|
+
* is folded too, which just drops the redundant central copy. A name declared
|
|
32
|
+
* locally with a DIFFERENT config is a genuine conflict: `onConflict: 'throw'`
|
|
33
|
+
* (the explicit `claim` command) surfaces it; `onConflict: 'skip'` (automatic
|
|
34
|
+
* eviction on sync) leaves it central so a stray duplicate can never wedge a
|
|
35
|
+
* sync — an operator resolves it with an explicit claim.
|
|
36
|
+
*/
|
|
37
|
+
function selectClaimableCentral(central, local, canHostHere, opts) {
|
|
38
|
+
const toClaim = {};
|
|
39
|
+
const skipped = [];
|
|
40
|
+
for (const [profileName, config] of Object.entries(central)) {
|
|
41
|
+
if (opts.name && profileName !== opts.name)
|
|
42
|
+
continue;
|
|
43
|
+
if (!canHostHere(config)) {
|
|
44
|
+
skipped.push(profileName);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const existing = local[profileName];
|
|
48
|
+
if (existing && !isDeepStrictEqual(existing, config)) {
|
|
49
|
+
if (opts.onConflict === 'throw') {
|
|
50
|
+
throw new Error(`Cannot migrate browser profile "${profileName}": central agents.yaml and this device's ` +
|
|
51
|
+
`agents.yaml declare different configurations. Resolve the duplicate before retrying.`);
|
|
52
|
+
}
|
|
53
|
+
skipped.push(profileName);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
toClaim[profileName] = config;
|
|
57
|
+
}
|
|
58
|
+
return { toClaim, skipped };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Return `current` with `toClaim` moved out of the central `browser:` map and
|
|
62
|
+
* into this device's `deviceBrowser` doc. The central key is dropped entirely
|
|
63
|
+
* once drained, so the tombstone disappears rather than lingering as an empty
|
|
64
|
+
* map. Pure — the caller applies it under the meta lock.
|
|
65
|
+
*/
|
|
66
|
+
function buildEvictedMeta(current, toClaim) {
|
|
67
|
+
const legacy = current;
|
|
68
|
+
const remaining = {};
|
|
69
|
+
for (const [profileName, config] of Object.entries(legacy.browser ?? {})) {
|
|
70
|
+
if (!(profileName in toClaim))
|
|
71
|
+
remaining[profileName] = config;
|
|
72
|
+
}
|
|
73
|
+
const { browser: _removed, ...withoutCentralBrowser } = legacy;
|
|
74
|
+
return {
|
|
75
|
+
...withoutCentralBrowser,
|
|
76
|
+
...(Object.keys(remaining).length > 0 ? { browser: remaining } : {}),
|
|
77
|
+
deviceBrowser: { ...current.deviceBrowser, ...toClaim },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Commit a claim computed by the EXPLICIT path (`migrateCentralBrowserProfiles`).
|
|
82
|
+
* `toClaim` was selected from an unlocked snapshot — acceptable for that manual,
|
|
83
|
+
* deliberate command; the automatic path instead selects INSIDE the lock (see
|
|
84
|
+
* {@link autoEvictCentralBrowserProfiles}) so an unrelated concurrent write can
|
|
85
|
+
* never be clobbered by a stale selection.
|
|
86
|
+
*/
|
|
87
|
+
function commitCentralClaim(toClaim) {
|
|
88
|
+
updateMeta((current) => buildEvictedMeta(current, toClaim));
|
|
89
|
+
}
|
|
16
90
|
/**
|
|
17
91
|
* Fold leftover central `browser:` entries into THIS device's declaration file.
|
|
18
92
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
93
|
+
* The EXPLICIT operator action (`agents browser profiles claim`), run on the
|
|
94
|
+
* machine that actually owns the browser. It is never called implicitly from a
|
|
95
|
+
* read, and that is the point: every device can read the central map, so an
|
|
96
|
+
* implicit claim races — whichever box reads first claims the name, {@link
|
|
97
|
+
* profileKind} then reports it `identity`, and the daemon tunnels to that box,
|
|
98
|
+
* which for a fleet-wide `cdp://localhost:*` profile is a logged-out headless
|
|
99
|
+
* browser wearing a credentialed browser's name. {@link autoEvictCentralBrowserProfiles}
|
|
100
|
+
* closes that race for the automatic path (host-gated + non-throwing on sync);
|
|
101
|
+
* this one is the manual, throw-on-conflict counterpart.
|
|
26
102
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* Only profiles this machine can host are claimed; the rest stay central,
|
|
32
|
-
* undeclared, and fail loudly on resolve.
|
|
103
|
+
* `canHostHere` is supplied by the command layer so this module stays a leaf —
|
|
104
|
+
* it must not import `isProfileLaunchableHere` (that would cycle through
|
|
105
|
+
* chrome.ts → profiles.ts). Only profiles this machine can host are claimed; the
|
|
106
|
+
* rest stay central, undeclared, and fail loudly on resolve.
|
|
33
107
|
*/
|
|
34
108
|
export function migrateCentralBrowserProfiles(canHostHere, name) {
|
|
35
109
|
const meta = readMeta();
|
|
@@ -53,42 +127,64 @@ export function migrateCentralBrowserProfiles(canHostHere, name) {
|
|
|
53
127
|
`(its browser/binary isn't installed here). Run this command on the machine that has that browser.`);
|
|
54
128
|
}
|
|
55
129
|
}
|
|
56
|
-
const toClaim = {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
continue;
|
|
61
|
-
if (!canHostHere(config)) {
|
|
62
|
-
skipped.push(profileName);
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
const existing = local[profileName];
|
|
66
|
-
if (existing && !isDeepStrictEqual(existing, config)) {
|
|
67
|
-
throw new Error(`Cannot migrate browser profile "${profileName}": central agents.yaml and this device's ` +
|
|
68
|
-
`agents.yaml declare different configurations. Resolve the duplicate before retrying.`);
|
|
69
|
-
}
|
|
70
|
-
toClaim[profileName] = config;
|
|
71
|
-
}
|
|
130
|
+
const { toClaim, skipped } = selectClaimableCentral(central, local, canHostHere, {
|
|
131
|
+
name,
|
|
132
|
+
onConflict: 'throw',
|
|
133
|
+
});
|
|
72
134
|
const claimed = Object.keys(toClaim).sort();
|
|
73
135
|
skipped.sort();
|
|
74
136
|
if (claimed.length === 0)
|
|
75
137
|
return { claimed, skipped };
|
|
76
|
-
|
|
77
|
-
const legacy = current;
|
|
78
|
-
const remaining = {};
|
|
79
|
-
for (const [profileName, config] of Object.entries(legacy.browser ?? {})) {
|
|
80
|
-
if (!(profileName in toClaim))
|
|
81
|
-
remaining[profileName] = config;
|
|
82
|
-
}
|
|
83
|
-
const { browser: _removed, ...withoutCentralBrowser } = legacy;
|
|
84
|
-
return {
|
|
85
|
-
...withoutCentralBrowser,
|
|
86
|
-
...(Object.keys(remaining).length > 0 ? { browser: remaining } : {}),
|
|
87
|
-
deviceBrowser: { ...current.deviceBrowser, ...toClaim },
|
|
88
|
-
};
|
|
89
|
-
});
|
|
138
|
+
commitCentralClaim(toClaim);
|
|
90
139
|
return { claimed, skipped };
|
|
91
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Automatic, self-draining counterpart to {@link migrateCentralBrowserProfiles}:
|
|
143
|
+
* fold every lingering central `browser:` profile THIS machine can host into its
|
|
144
|
+
* device doc and clear it from central, so the tombstone drains itself on
|
|
145
|
+
* `agents sync` with no manual `agents browser profiles claim`.
|
|
146
|
+
*
|
|
147
|
+
* Safe to call implicitly, unlike a claim at registry-read time (which races —
|
|
148
|
+
* see the `profileRegistry does not claim central declarations` guard), but the
|
|
149
|
+
* CALLER owns one ordering invariant: invoke it AFTER the sync's repo pull. Two
|
|
150
|
+
* boxes can both host the same profile (`canHostHere` is launchability, not
|
|
151
|
+
* ownership), so a box acting on a PRE-pull view of central could re-claim a
|
|
152
|
+
* profile a peer already drained and pushed — both writes land in different
|
|
153
|
+
* device files with no git conflict, and the profile flips identity->fungible.
|
|
154
|
+
* Running post-pull means central already reflects peers' drains, so the tombstone
|
|
155
|
+
* is gone locally before this box looks. The selection is also computed INSIDE
|
|
156
|
+
* the meta lock, and the path never throws — an unhostable profile or one that
|
|
157
|
+
* conflicts with an existing local declaration is left central for an explicit
|
|
158
|
+
* claim rather than wedging the sync. After it runs, {@link profileRegistry} is
|
|
159
|
+
* the single source of truth: a claimed profile lives in the device doc alone,
|
|
160
|
+
* never double-counted across the two stores.
|
|
161
|
+
*/
|
|
162
|
+
export function autoEvictCentralBrowserProfiles(canHostHere) {
|
|
163
|
+
// Read fresh, select, and commit inside ONE meta-lock acquisition. Reading
|
|
164
|
+
// central before the lock and committing after (the manual path's shape) leaves
|
|
165
|
+
// a window where a concurrent write on THIS machine changes central/deviceBrowser
|
|
166
|
+
// between snapshot and commit, so the stale selection would be merged over the
|
|
167
|
+
// fresher value. Selecting from the state the lock just handed us closes that
|
|
168
|
+
// window. We write ONLY when something is claimed, so a no-op sync never touches
|
|
169
|
+
// any doc (writeMetaUnlocked would otherwise re-serialize the device doc every
|
|
170
|
+
// run). This mirrors updateMeta's own body, minus the unconditional write.
|
|
171
|
+
return withMetaLock(() => {
|
|
172
|
+
const meta = readMeta();
|
|
173
|
+
const central = meta.browser;
|
|
174
|
+
if (!central || Object.keys(central).length === 0)
|
|
175
|
+
return { claimed: [], skipped: [] };
|
|
176
|
+
const local = meta.deviceBrowser ?? {};
|
|
177
|
+
const { toClaim, skipped } = selectClaimableCentral(central, local, canHostHere, {
|
|
178
|
+
onConflict: 'skip',
|
|
179
|
+
});
|
|
180
|
+
const claimed = Object.keys(toClaim).sort();
|
|
181
|
+
skipped.sort();
|
|
182
|
+
if (claimed.length === 0)
|
|
183
|
+
return { claimed, skipped };
|
|
184
|
+
writeMetaUnlocked(buildEvictedMeta(meta, toClaim));
|
|
185
|
+
return { claimed, skipped };
|
|
186
|
+
});
|
|
187
|
+
}
|
|
92
188
|
/**
|
|
93
189
|
* Every profile any device declares, keyed by name to all declaring devices.
|
|
94
190
|
* Reads every `devices/<name>/agents.yaml`; declarations never overwrite.
|
|
@@ -203,8 +203,10 @@ export declare const PRUNE_REASON_TEXT: Record<PruneReason, string>;
|
|
|
203
203
|
* - **the configured default** — this machine resolves a bare
|
|
204
204
|
* `agents browser start` to it; removing it breaks that command.
|
|
205
205
|
* - **the auto-detected profile** (`auto-chrome`, or the `default` an older
|
|
206
|
-
* build wrote) —
|
|
207
|
-
*
|
|
206
|
+
* build wrote) — still recognized and resolved by
|
|
207
|
+
* `ensureDefaultBrowserProfile` as this machine's default; since PHNX-3296
|
|
208
|
+
* it is no longer re-created on demand, so pruning it forces the user
|
|
209
|
+
* back through `agents setup` to get a default browser again.
|
|
208
210
|
*
|
|
209
211
|
* Known limitation: `BrowserProfileConfig` records no creation time, so a
|
|
210
212
|
* profile created seconds ago and not yet started is indistinguishable from an
|
|
@@ -343,8 +343,10 @@ export const PRUNE_REASON_TEXT = {
|
|
|
343
343
|
* - **the configured default** — this machine resolves a bare
|
|
344
344
|
* `agents browser start` to it; removing it breaks that command.
|
|
345
345
|
* - **the auto-detected profile** (`auto-chrome`, or the `default` an older
|
|
346
|
-
* build wrote) —
|
|
347
|
-
*
|
|
346
|
+
* build wrote) — still recognized and resolved by
|
|
347
|
+
* `ensureDefaultBrowserProfile` as this machine's default; since PHNX-3296
|
|
348
|
+
* it is no longer re-created on demand, so pruning it forces the user
|
|
349
|
+
* back through `agents setup` to get a default browser again.
|
|
348
350
|
*
|
|
349
351
|
* Known limitation: `BrowserProfileConfig` records no creation time, so a
|
|
350
352
|
* profile created seconds ago and not yet started is indistinguishable from an
|
|
@@ -2330,9 +2330,10 @@ export class BrowserService {
|
|
|
2330
2330
|
if (!opts.createIfMissing)
|
|
2331
2331
|
return null;
|
|
2332
2332
|
// The top-of-function consent gate already refused a fleet-remote create
|
|
2333
|
-
// here — before ensureDefaultBrowserProfile() below
|
|
2334
|
-
//
|
|
2335
|
-
//
|
|
2333
|
+
// here — before ensureDefaultBrowserProfile() below resolves a default — so
|
|
2334
|
+
// a refused request never touches the target machine. Since PHNX-3296 that
|
|
2335
|
+
// resolver never mints a profile: with no launchable default it throws, and
|
|
2336
|
+
// the throw surfaces to the caller exactly like any other start failure.
|
|
2336
2337
|
// Implicit start on the default / named profile.
|
|
2337
2338
|
let profileName = opts.profile;
|
|
2338
2339
|
if (!profileName) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forward an owner notification to a capable fleet peer over SSH (PHNX-3303).
|
|
3
|
+
*
|
|
4
|
+
* The owner's delivery provider for the rush-backed channels (imessage /
|
|
5
|
+
* telegram / slack / discord via the `rush` CLI) is macOS-only and
|
|
6
|
+
* keychain-bound, so a headless Linux worker structurally CANNOT ring the
|
|
7
|
+
* owner's phone: `agents feed post --level important` records the post but the
|
|
8
|
+
* owner sink fails with `rush CLI not found on PATH`, and the important post
|
|
9
|
+
* reaches nobody. `probeOwnerSink` (owner-sink.ts) already reports this as the
|
|
10
|
+
* `owner-sink-unreachable` doctor finding; this module is the runtime answer to
|
|
11
|
+
* it — instead of stranding the failure, hand the delivery to a reachable macOS
|
|
12
|
+
* peer that DOES have the provider.
|
|
13
|
+
*
|
|
14
|
+
* This mirrors the SSH reroute `agents message` (decideHostTaskRoute →
|
|
15
|
+
* runOnPeer) and the sessions fan-out already use for work that lives on another
|
|
16
|
+
* box: pick a reachable peer from the device registry and run the same `agents`
|
|
17
|
+
* verb there. Here the verb is `agents send --to owner`, which resolves the
|
|
18
|
+
* peer's own (fleet-synced) owner destination and delivers through its local
|
|
19
|
+
* rush — so the owner is addressed once, from the one box that can reach them.
|
|
20
|
+
*
|
|
21
|
+
* Best-effort seam: it never throws and never blocks the post. When no capable
|
|
22
|
+
* peer is reachable it resolves `undefined` and the caller keeps its original
|
|
23
|
+
* clean local error, exactly as before.
|
|
24
|
+
*/
|
|
25
|
+
import type { Meta } from '../types.js';
|
|
26
|
+
import type { SendResult } from './registry.js';
|
|
27
|
+
import type { DeviceProfile } from '../devices/registry.js';
|
|
28
|
+
/**
|
|
29
|
+
* Env marker set on the forwarded `agents send` so a box that received a
|
|
30
|
+
* forwarded owner notify never forwards it onward. `agents send` does not route
|
|
31
|
+
* through this module today, so this is defense-in-depth against a future
|
|
32
|
+
* consumer wiring forwarding into the send path and creating a fan-out loop.
|
|
33
|
+
*/
|
|
34
|
+
export declare const OWNER_FORWARD_GUARD_ENV = "AGENTS_OWNER_NO_FORWARD";
|
|
35
|
+
/** Why forwarding did not run, so a caller/test can assert the decision. */
|
|
36
|
+
export type OwnerForwardSkip = 'guarded' | 'not-rush-backed' | 'no-capable-peer';
|
|
37
|
+
export interface OwnerForwardPlan {
|
|
38
|
+
/** Ordered machine ids to try — capable (macOS), reachable, self excluded. */
|
|
39
|
+
candidates: string[];
|
|
40
|
+
/** Set when forwarding does not apply; the caller keeps its local error. */
|
|
41
|
+
skip?: OwnerForwardSkip;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* True when the resolved owner transport is the macOS-only rush family — the
|
|
45
|
+
* one case a Linux/headless box structurally cannot deliver and a peer can.
|
|
46
|
+
* `openclaw-telegram` and the local `desktop`/`mailbox` providers are NOT
|
|
47
|
+
* rush-backed, so a failure there is not a wrong-OS problem and is left as-is.
|
|
48
|
+
* Mirrors the same `RUSH_CHANNELS.includes(transport)` gate in owner-sink.ts.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isRushBackedTransport(channel: string, meta: Meta): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Decide which peers can deliver the owner notification, in try order. Pure —
|
|
53
|
+
* no I/O — so the channel gate, the recursion guard, self-exclusion, and the
|
|
54
|
+
* capability/ordering rules are unit-testable without a live tailnet.
|
|
55
|
+
*
|
|
56
|
+
* Only macOS peers are candidates: the rush owner transport is macOS-only, so a
|
|
57
|
+
* Linux/Windows peer could not deliver it either. The configured
|
|
58
|
+
* `interactive.host` (the box the operator sits at, where rush is signed in) is
|
|
59
|
+
* tried first when it is among the candidates.
|
|
60
|
+
*/
|
|
61
|
+
export declare function planOwnerForward(channel: string, meta: Meta, devices: DeviceProfile[], self: string, opts?: {
|
|
62
|
+
guarded?: boolean;
|
|
63
|
+
}): OwnerForwardPlan;
|
|
64
|
+
/**
|
|
65
|
+
* Deliver `text` to the owner FROM one peer over SSH. Runs the peer's own
|
|
66
|
+
* `agents send --to owner --text <text> --json`, which resolves that box's
|
|
67
|
+
* fleet-synced owner destination and delivers through its local provider.
|
|
68
|
+
* Resolves the parsed `SendResult`, or `undefined` when the peer is
|
|
69
|
+
* unreachable / not a dialable device / answered with unparseable output —
|
|
70
|
+
* every one of which means "try the next peer".
|
|
71
|
+
*/
|
|
72
|
+
export type PeerOwnerSender = (machine: string, text: string) => Promise<SendResult | undefined>;
|
|
73
|
+
/**
|
|
74
|
+
* Try each capable peer in order and return the first successful delivery. A
|
|
75
|
+
* peer that is unreachable or reports its own delivery failure is skipped and
|
|
76
|
+
* the next is tried; the first `ok:true` wins and stops the sweep so the owner's
|
|
77
|
+
* phone rings once. Resolves `undefined` when forwarding does not apply or no
|
|
78
|
+
* peer delivered — the caller then keeps its original local error.
|
|
79
|
+
*
|
|
80
|
+
* The transport (`send`) is injectable so the try-order / first-success / stop
|
|
81
|
+
* orchestration is testable without a live SSH host; the default runs the real
|
|
82
|
+
* `agents send --to owner` over SSH.
|
|
83
|
+
*/
|
|
84
|
+
export declare function forwardOwnerNotifyToPeer(text: string, channel: string, meta: Meta, opts?: {
|
|
85
|
+
self?: string;
|
|
86
|
+
devices?: DeviceProfile[];
|
|
87
|
+
send?: PeerOwnerSender;
|
|
88
|
+
}): Promise<SendResult | undefined>;
|