@phnx-labs/agents-cli 1.22.45 → 1.22.47
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 +303 -0
- package/LICENSE +102 -182
- package/README.md +54 -17
- package/dist/cli/command-registry.d.ts +2 -1
- package/dist/cli/command-registry.js +6 -2
- package/dist/commands/accounts.js +56 -12
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +229 -0
- package/dist/commands/browser-sessions-picker.js +9 -7
- package/dist/commands/browser.js +266 -15
- package/dist/commands/config.js +26 -4
- package/dist/commands/cost.d.ts +1 -1
- package/dist/commands/detach.d.ts +13 -0
- package/dist/commands/detach.js +11 -8
- package/dist/commands/exec.js +55 -38
- package/dist/commands/feed-watch.d.ts +2 -0
- package/dist/commands/feed-watch.js +28 -0
- package/dist/commands/feed.js +27 -0
- package/dist/commands/feedback.js +3 -19
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +2 -2
- package/dist/commands/lease.js +3 -3
- package/dist/commands/routines.js +1 -1
- package/dist/commands/sessions-picker-factory.d.ts +2 -1
- package/dist/commands/sessions-picker-factory.js +1 -1
- package/dist/commands/sessions-stop.d.ts +24 -0
- package/dist/commands/sessions-stop.js +66 -0
- package/dist/commands/sessions-trace.d.ts +82 -0
- package/dist/commands/sessions-trace.js +387 -0
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.js +194 -16
- package/dist/commands/teams.js +34 -2
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +19 -32
- package/dist/commands/watchdog.js +9 -14
- package/dist/lib/account-capabilities.d.ts +4 -0
- package/dist/lib/account-capabilities.js +13 -9
- package/dist/lib/account-registry.d.ts +2 -0
- package/dist/lib/account-registry.js +30 -2
- package/dist/lib/accounting/rotate.d.ts +9 -0
- package/dist/lib/accounting/rotate.js +12 -2
- package/dist/lib/accounting/usage.d.ts +92 -6
- package/dist/lib/accounting/usage.js +329 -75
- package/dist/lib/agent-modes.js +29 -1
- package/dist/lib/agent-spec/agents.d.ts +4 -3
- package/dist/lib/agent-spec/agents.js +20 -7
- package/dist/lib/analytics/mix-commands.d.ts +1 -1
- package/dist/lib/analytics/mix-commands.js +2 -2
- package/dist/lib/auth-health.d.ts +13 -3
- package/dist/lib/auth-health.js +50 -8
- package/dist/lib/browser/devices.d.ts +19 -0
- package/dist/lib/browser/devices.js +23 -0
- package/dist/lib/browser/ipc.d.ts +1 -1
- package/dist/lib/browser/ipc.js +33 -7
- package/dist/lib/browser/profiles.d.ts +106 -1
- package/dist/lib/browser/profiles.js +300 -17
- package/dist/lib/browser/remote-control.d.ts +26 -2
- package/dist/lib/browser/remote-control.js +33 -3
- package/dist/lib/browser/runtime-state.d.ts +9 -0
- package/dist/lib/browser/runtime-state.js +24 -8
- package/dist/lib/browser/service.d.ts +37 -0
- package/dist/lib/browser/service.js +78 -20
- package/dist/lib/browser/sessions-list.d.ts +1 -3
- package/dist/lib/browser/sessions-list.js +3 -16
- package/dist/lib/browser/types.d.ts +12 -1
- package/dist/lib/cli-resources.js +17 -15
- package/dist/lib/codex-policy.d.ts +11 -1
- package/dist/lib/codex-policy.js +20 -5
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +17 -7
- package/dist/lib/daemon/runner.js +15 -2
- package/dist/lib/daemon-ticks.js +2 -2
- package/dist/lib/device-config.d.ts +25 -8
- package/dist/lib/device-config.js +90 -17
- package/dist/lib/devices/config-migration.d.ts +7 -0
- package/dist/lib/devices/config-migration.js +67 -3
- package/dist/lib/devices/harness-inventory.js +20 -3
- package/dist/lib/devices/health.d.ts +27 -2
- package/dist/lib/devices/health.js +39 -9
- package/dist/lib/devices/interactive-host.d.ts +15 -0
- package/dist/lib/devices/interactive-host.js +53 -0
- package/dist/lib/devices/registry.d.ts +61 -4
- package/dist/lib/devices/registry.js +94 -38
- package/dist/lib/devices/stats-cache.d.ts +6 -0
- package/dist/lib/devices/stats-cache.js +10 -2
- package/dist/lib/exec.d.ts +25 -3
- package/dist/lib/exec.js +75 -22
- package/dist/lib/feed/answer.d.ts +29 -0
- package/dist/lib/feed/answer.js +145 -0
- package/dist/lib/feed/attention.d.ts +83 -0
- package/dist/lib/feed/attention.js +0 -0
- package/dist/lib/feed/feed.d.ts +109 -0
- package/dist/lib/feed/feed.js +154 -3
- package/dist/lib/feed/pr-status.d.ts +14 -0
- package/dist/lib/feed/pr-status.js +47 -0
- package/dist/lib/feed/watch.d.ts +59 -0
- package/dist/lib/feed/watch.js +180 -0
- package/dist/lib/fleet/capture.js +6 -0
- package/dist/lib/fleet/remote-login.js +8 -3
- package/dist/lib/fleet/types.d.ts +17 -0
- package/dist/lib/harness/adapter.d.ts +11 -0
- package/dist/lib/harness/adapters/claude.js +38 -24
- package/dist/lib/harness/adapters/codex.js +2 -4
- package/dist/lib/hooks/cache.js +8 -0
- package/dist/lib/hooks/match.d.ts +3 -0
- package/dist/lib/hooks/match.js +25 -0
- package/dist/lib/hosts/passthrough.js +16 -0
- package/dist/lib/hosts/registry.js +7 -0
- package/dist/lib/identity/client.d.ts +53 -0
- package/dist/lib/identity/client.js +106 -0
- package/dist/lib/identity/index.d.ts +115 -0
- package/dist/lib/identity/index.js +82 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +13 -0
- package/dist/lib/menubar/install-menubar.js +26 -2
- package/dist/lib/open-url.d.ts +60 -2
- package/dist/lib/open-url.js +167 -12
- package/dist/lib/probe.d.ts +8 -0
- package/dist/lib/probe.js +105 -0
- package/dist/lib/refresh-coordinator.d.ts +1 -1
- package/dist/lib/refresh-coordinator.js +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/session/active.d.ts +67 -0
- package/dist/lib/session/active.js +69 -1
- package/dist/lib/session/db.js +4 -2
- package/dist/lib/session/discover.d.ts +2 -0
- package/dist/lib/session/discover.js +12 -6
- package/dist/lib/session/prompt.d.ts +35 -0
- package/dist/lib/session/prompt.js +56 -0
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +6 -1
- package/dist/lib/session/render.js +7 -3
- package/dist/lib/session/session-cache.js +5 -1
- package/dist/lib/session/trajectory-compare.d.ts +47 -0
- package/dist/lib/session/trajectory-compare.js +116 -0
- package/dist/lib/session/trajectory-html.d.ts +15 -0
- package/dist/lib/session/trajectory-html.js +750 -0
- package/dist/lib/session/trajectory-lineage.d.ts +92 -0
- package/dist/lib/session/trajectory-lineage.js +195 -0
- package/dist/lib/session/trajectory-text.d.ts +37 -0
- package/dist/lib/session/trajectory-text.js +277 -0
- package/dist/lib/session/trajectory.d.ts +102 -0
- package/dist/lib/session/trajectory.js +335 -0
- package/dist/lib/startup/command-registry.d.ts +5 -2
- package/dist/lib/startup/command-registry.js +8 -5
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +4 -2
- package/dist/lib/teams/agents.js +9 -0
- package/dist/lib/terminal/inject.d.ts +19 -0
- package/dist/lib/terminal/inject.js +19 -9
- package/dist/lib/tmux/session.d.ts +46 -13
- package/dist/lib/tmux/session.js +173 -27
- package/dist/lib/types.d.ts +16 -0
- package/dist/lib/usage-backoff.d.ts +2 -1
- package/dist/lib/usage-backoff.js +60 -19
- package/dist/lib/usage-refresh.d.ts +17 -3
- package/dist/lib/usage-refresh.js +57 -11
- package/dist/lib/view-types.d.ts +2 -2
- package/dist/lib/watchdog/log.d.ts +1 -1
- package/dist/lib/watchdog/log.js +1 -1
- package/dist/lib/watchdog/runner.d.ts +36 -45
- package/dist/lib/watchdog/runner.js +150 -119
- package/dist/lib/watchdog/service.d.ts +0 -1
- package/dist/lib/watchdog/service.js +0 -1
- package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
- package/dist/lib/watchdog/watchdog-agent.js +59 -0
- package/dist/lib/watchdog/watchdog.d.ts +11 -2
- package/dist/lib/watchdog/watchdog.js +37 -104
- package/package.json +2 -2
- package/dist/commands/usage.d.ts +0 -27
- package/dist/commands/usage.js +0 -96
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as path from 'path';
|
|
2
|
+
import * as fs from 'fs';
|
|
2
3
|
import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, readMeta, updateMeta, } from '../state.js';
|
|
3
4
|
import { getConfigValue } from '../device-config.js';
|
|
4
5
|
import { findBrowserPath, findFirstInstalledBrowser, isPortInUse } from './chrome.js';
|
|
@@ -419,6 +420,32 @@ function hasSshEndpoint(endpoints) {
|
|
|
419
420
|
}
|
|
420
421
|
});
|
|
421
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* Refuse a profile whose LOCAL port another profile already owns.
|
|
425
|
+
*
|
|
426
|
+
* Every CDP/SSH profile ends up listening on (or tunneling to) the same LOCAL
|
|
427
|
+
* port number as the one configured in the endpoint URL — SSH profiles reuse
|
|
428
|
+
* `?port=N` locally, so we no longer scope by host. Two profiles that would
|
|
429
|
+
* need the same local port can't both run at the same time.
|
|
430
|
+
*
|
|
431
|
+
* `opts.ignore` is the profile's own name on an edit. Without it every edit
|
|
432
|
+
* collides with itself, since the stored copy still owns the port being kept.
|
|
433
|
+
*/
|
|
434
|
+
export function assertLocalPortFree(profile, opts = {}) {
|
|
435
|
+
const wanted = effectiveLocalPort(profile);
|
|
436
|
+
if (wanted === undefined)
|
|
437
|
+
return;
|
|
438
|
+
for (const [existingName, existingConfig] of Object.entries(allProfileConfigs())) {
|
|
439
|
+
if (existingName === opts.ignore)
|
|
440
|
+
continue;
|
|
441
|
+
const existingProfile = configToProfile(existingName, existingConfig);
|
|
442
|
+
if (effectiveLocalPort(existingProfile) === wanted) {
|
|
443
|
+
throw new Error(`Local port ${wanted} is already used by profile "${existingName}". ` +
|
|
444
|
+
`Each profile must own a unique local port (SSH tunnels now bind ` +
|
|
445
|
+
`to their configured port locally too). Pick a different port.`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
422
449
|
/**
|
|
423
450
|
* Create a profile. **It lands in this machine's own store by default** — pass
|
|
424
451
|
* `{ fleet: true }` (the CLI's `--fleet`) to put it in the synced `agents.yaml`
|
|
@@ -442,23 +469,7 @@ export async function createProfile(profile, opts = {}) {
|
|
|
442
469
|
if (existingConfigs[profile.name]) {
|
|
443
470
|
throw new Error(`Profile "${profile.name}" already exists`);
|
|
444
471
|
}
|
|
445
|
-
|
|
446
|
-
// tunneling to) the same LOCAL port number as the one configured in the
|
|
447
|
-
// endpoint URL — SSH profiles now reuse `?port=N` locally so we no
|
|
448
|
-
// longer need to scope by host. Two profiles that would need the same
|
|
449
|
-
// local port can't both run at the same time.
|
|
450
|
-
const newLocal = effectiveLocalPort(profile);
|
|
451
|
-
if (newLocal !== undefined) {
|
|
452
|
-
for (const [existingName, existingConfig] of Object.entries(existingConfigs)) {
|
|
453
|
-
const existingProfile = configToProfile(existingName, existingConfig);
|
|
454
|
-
const existingLocal = effectiveLocalPort(existingProfile);
|
|
455
|
-
if (existingLocal === newLocal) {
|
|
456
|
-
throw new Error(`Local port ${newLocal} is already used by profile "${existingName}". ` +
|
|
457
|
-
`Each profile must own a unique local port (SSH tunnels now bind ` +
|
|
458
|
-
`to their configured port locally too). Pick a different port.`);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
}
|
|
472
|
+
assertLocalPortFree(profile);
|
|
462
473
|
// Resolve the browser binary at create time. Fails fast with an actionable
|
|
463
474
|
// error ("Comet not installed at /Applications/Comet.app") rather than
|
|
464
475
|
// deferring the failure to the first task. `findBrowserPath` short-circuits
|
|
@@ -496,6 +507,278 @@ export async function updateProfile(profile) {
|
|
|
496
507
|
}
|
|
497
508
|
updateMeta((m) => ({ ...m, browser: { ...m.browser, [profile.name]: config } }));
|
|
498
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* Flag a fleet-synced profile whose contents only make sense on ONE machine.
|
|
512
|
+
*
|
|
513
|
+
* A `cdp://` endpoint on loopback always connects on the machine EVALUATING the
|
|
514
|
+
* profile, so a fleet-scope copy does not mean "that browser" — it means "port N
|
|
515
|
+
* on whichever box ran the command". The name then silently resolves to a
|
|
516
|
+
* different, usually logged-out browser on every other machine, which is the
|
|
517
|
+
* worst possible failure for a profile that carries live logins.
|
|
518
|
+
*
|
|
519
|
+
* Reported by `profiles doctor` (a failing `scope` check) and noted in the
|
|
520
|
+
* `kept` reason `profiles prune` prints for a fleet profile. The repair is
|
|
521
|
+
* {@link moveProfileScope}, never an automatic rewrite of the synced doc.
|
|
522
|
+
*/
|
|
523
|
+
export function misfiledFleetProfile(profile, scope) {
|
|
524
|
+
if (scope !== 'fleet')
|
|
525
|
+
return { misfiled: false };
|
|
526
|
+
// Judge the endpoint the profile would actually CONNECT on, resolving
|
|
527
|
+
// `defaultEndpoint` exactly as effectiveLocalPort does. Scanning every preset
|
|
528
|
+
// instead would fail a legitimate fleet profile that defaults to `ssh://` and
|
|
529
|
+
// merely carries a loopback alternate for local debugging.
|
|
530
|
+
const presets = getEndpointPresets(profile);
|
|
531
|
+
const chosen = profile.defaultEndpoint && presets[profile.defaultEndpoint]
|
|
532
|
+
? profile.defaultEndpoint
|
|
533
|
+
: Object.keys(presets)[0];
|
|
534
|
+
if (!chosen)
|
|
535
|
+
return { misfiled: false };
|
|
536
|
+
const loopback = presets[chosen].target;
|
|
537
|
+
if (!loopback.startsWith('cdp://'))
|
|
538
|
+
return { misfiled: false };
|
|
539
|
+
const parsed = parseEndpointUrl(loopback);
|
|
540
|
+
if (!parsed || !isLocalHost(parsed.host))
|
|
541
|
+
return { misfiled: false };
|
|
542
|
+
return {
|
|
543
|
+
misfiled: true,
|
|
544
|
+
why: `fleet-synced but "${loopback}" is a loopback endpoint — on every other machine ` +
|
|
545
|
+
`this name resolves to that box's own port, not this browser. ` +
|
|
546
|
+
`Repair on the machine that owns it: agents browser profiles scope ${profile.name} local`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Merge `patch` onto the stored profile and persist it in the store it already
|
|
551
|
+
* lives in (see {@link updateProfile} for the scope rules).
|
|
552
|
+
*
|
|
553
|
+
* Runs {@link createProfile}'s validations against the MERGED record, not just
|
|
554
|
+
* the patched fields — a binary edit re-resolves the browser path, a
|
|
555
|
+
* targetFilter edit re-checks the electron gate, and the local-port scan runs
|
|
556
|
+
* with this profile excluded so an unchanged port is not a self-collision.
|
|
557
|
+
*/
|
|
558
|
+
export async function editProfile(name, patch) {
|
|
559
|
+
const meta = readMeta();
|
|
560
|
+
const local = meta.deviceBrowser?.[name];
|
|
561
|
+
const fleet = meta.browser?.[name];
|
|
562
|
+
if (!local && !fleet) {
|
|
563
|
+
throw new Error(`Profile "${name}" does not exist`);
|
|
564
|
+
}
|
|
565
|
+
const current = configToProfile(name, (local ?? fleet));
|
|
566
|
+
const merged = { ...current, ...patch, name };
|
|
567
|
+
const changed = Object.keys(patch).filter((k) => JSON.stringify(current[k]) !== JSON.stringify(merged[k]));
|
|
568
|
+
// A target filter only means anything for an Electron app, and the gate must
|
|
569
|
+
// read the MERGED record: `--target-filter x` on a profile that is already
|
|
570
|
+
// electron is valid, and `--no-electron` on one that still carries a filter is
|
|
571
|
+
// not. Checking the patch alone would accept both.
|
|
572
|
+
if (merged.targetFilter && !merged.electron) {
|
|
573
|
+
throw new Error(`--target-filter only applies to an Electron profile. ` +
|
|
574
|
+
`Pass --electron, or clear the filter with --target-filter ''.`);
|
|
575
|
+
}
|
|
576
|
+
assertLocalPortFree(merged, { ignore: name });
|
|
577
|
+
// Same rule as create: the binary lives on the remote for an SSH profile, so a
|
|
578
|
+
// local lookup would validate the wrong machine.
|
|
579
|
+
if (!hasSshEndpoint(merged.endpoints)) {
|
|
580
|
+
findBrowserPath(merged.browser, merged.binary);
|
|
581
|
+
}
|
|
582
|
+
await updateProfile(merged);
|
|
583
|
+
const landedLocal = !!local || isMachineLocalProfile(name);
|
|
584
|
+
return {
|
|
585
|
+
profile: merged,
|
|
586
|
+
scope: landedLocal ? 'local' : 'fleet',
|
|
587
|
+
changed,
|
|
588
|
+
fleetCopyLeftStale: landedLocal && !local && !!fleet ? true : undefined,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Move an existing profile between the fleet-synced store and this machine's
|
|
593
|
+
* device store.
|
|
594
|
+
*
|
|
595
|
+
* `fleet -> local` is the repair for a profile whose endpoint is machine-bound
|
|
596
|
+
* (a loopback `cdp://`) but which was created with `--fleet`: the name resolves
|
|
597
|
+
* to a DIFFERENT browser on every box, because `cdp:` always connects on the
|
|
598
|
+
* machine evaluating it. Run it ON the machine that owns the browser — the copy
|
|
599
|
+
* that survives is the one that works.
|
|
600
|
+
*
|
|
601
|
+
* Deliberately not automatic. Rewriting the user's synced `agents.yaml` from a
|
|
602
|
+
* heuristic is the rewrite loop RUSH-2161 fixed; this is an explicit verb.
|
|
603
|
+
*/
|
|
604
|
+
/** Profile names an agent has to be able to type and an fs path can hold. */
|
|
605
|
+
const PROFILE_NAME_RE = /^[a-z][a-z0-9-]*$/;
|
|
606
|
+
/**
|
|
607
|
+
* Throw if `name` cannot be used for a NEW profile.
|
|
608
|
+
*
|
|
609
|
+
* Shared by `profiles create` and {@link renameProfile} so the two cannot drift
|
|
610
|
+
* — the shape rule used to live inline in the command, which is how `rename`
|
|
611
|
+
* would have accepted a name `create` rejects.
|
|
612
|
+
*
|
|
613
|
+
* `default` is refused because it is the reserved ALIAS meaning "whatever
|
|
614
|
+
* profile this machine is configured to use" (RUSH-2709). A literal profile by
|
|
615
|
+
* that name makes `--profile default` mean two different things.
|
|
616
|
+
*/
|
|
617
|
+
export function assertRegistrableProfileName(name) {
|
|
618
|
+
if (!PROFILE_NAME_RE.test(name)) {
|
|
619
|
+
throw new Error(`Invalid profile name ${JSON.stringify(name)}. Use lowercase letters, digits and hyphens, ` +
|
|
620
|
+
`starting with a letter — e.g. 'agents'.`);
|
|
621
|
+
}
|
|
622
|
+
if (name === DEFAULT_PROFILE_ALIAS) {
|
|
623
|
+
throw new Error(`"${DEFAULT_PROFILE_ALIAS}" is the reserved alias for this machine's configured profile, ` +
|
|
624
|
+
`not a profile name. Pick another name.`);
|
|
625
|
+
}
|
|
626
|
+
// `browser.viewer: os` means "use the OS default handler". A profile by that
|
|
627
|
+
// name turns the opt-out into a pointer at a profile — silently, since both
|
|
628
|
+
// are just strings in the same key.
|
|
629
|
+
if (name === 'os') {
|
|
630
|
+
throw new Error(`"os" is the reserved browser.viewer value meaning the OS default handler, ` +
|
|
631
|
+
`not a profile name. Pick another name.`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Rename a profile, taking its on-disk state with it.
|
|
636
|
+
*
|
|
637
|
+
* `editProfile` deliberately refuses a name change, and this is why: the name
|
|
638
|
+
* keys the runtime dir ({@link getProfileRuntimeDir}), every fork/endpoint dir
|
|
639
|
+
* derived from it, and the `browser.profile` pointer. Delete-and-recreate — the
|
|
640
|
+
* only route before this — silently abandons the browser's `--user-data-dir`,
|
|
641
|
+
* which is where a profile's logins live. On a real agent browser that is
|
|
642
|
+
* gigabytes of session state and every account it has ever signed into.
|
|
643
|
+
*
|
|
644
|
+
* Refuses when the profile is in use: moving a `--user-data-dir` out from under
|
|
645
|
+
* a running browser corrupts it.
|
|
646
|
+
*/
|
|
647
|
+
export async function renameProfile(from, to) {
|
|
648
|
+
if (from === to)
|
|
649
|
+
throw new Error(`"${from}" is already its own name.`);
|
|
650
|
+
const meta = readMeta();
|
|
651
|
+
const local = meta.deviceBrowser?.[from];
|
|
652
|
+
const fleet = meta.browser?.[from];
|
|
653
|
+
if (!local && !fleet)
|
|
654
|
+
throw new Error(`Profile "${from}" does not exist`);
|
|
655
|
+
if (allProfileConfigs()[to])
|
|
656
|
+
throw new Error(`Profile "${to}" already exists`);
|
|
657
|
+
assertRegistrableProfileName(to);
|
|
658
|
+
const { isProfileInUse, listProfileCacheDirs } = await import('./runtime-state.js');
|
|
659
|
+
if (isProfileInUse(from)) {
|
|
660
|
+
throw new Error(`"${from}" is in use (live browser, tunnel, or open task). Renaming would move its ` +
|
|
661
|
+
`user-data-dir out from under the running browser. Stop it first: agents browser stop --profile ${from}`);
|
|
662
|
+
}
|
|
663
|
+
// A name can exist in BOTH stores (local wins in allProfileConfigs). Rewriting
|
|
664
|
+
// only one leaves the other listed under the OLD name with its data dir already
|
|
665
|
+
// moved away — the abandoned-`--user-data-dir` state this command exists to
|
|
666
|
+
// prevent, produced by this command, and previously reported as a clean
|
|
667
|
+
// success. Refuse instead: which copy the user means is genuinely ambiguous,
|
|
668
|
+
// and `profiles scope` is the tool for collapsing the duplicate first.
|
|
669
|
+
if (local && fleet) {
|
|
670
|
+
// `scope <name> fleet`, NOT local: moveProfileScope computes `from` as
|
|
671
|
+
// 'local' whenever a local entry exists, so asking for local is a no-op that
|
|
672
|
+
// reports success and leaves the duplicate. Sending the user there and then
|
|
673
|
+
// to `profiles delete` — which drops the cached runtime dirs by default —
|
|
674
|
+
// walked a data-preservation command into a data-destroying one.
|
|
675
|
+
throw new Error(`"${from}" exists in BOTH this machine's store and the fleet-synced one. ` +
|
|
676
|
+
`Renaming would move its browser data while leaving one copy behind under the old name.\n` +
|
|
677
|
+
` Collapse the duplicate first: agents browser profiles scope ${from} fleet\n` +
|
|
678
|
+
` Then rename. (To drop a copy instead, agents browser profiles delete ${from} --keep-cache ` +
|
|
679
|
+
`keeps the browser data.)`);
|
|
680
|
+
}
|
|
681
|
+
const config = (local ?? fleet);
|
|
682
|
+
const scope = local ? 'local' : 'fleet';
|
|
683
|
+
// Move the on-disk state BEFORE the config, so a crash between the two leaves
|
|
684
|
+
// a profile whose dirs are already where the new name expects them rather than
|
|
685
|
+
// a config pointing at dirs that no longer exist.
|
|
686
|
+
// PRE-FLIGHT every destination before moving ANY of them. With the check
|
|
687
|
+
// inside the loop, dir N was validated only after dirs 0..N-1 had already
|
|
688
|
+
// moved: a collision on the second endpoint left the first one's logins
|
|
689
|
+
// stranded under a name with no config entry, and the error named only the
|
|
690
|
+
// squatter. Nothing repaired that, and the user was never told.
|
|
691
|
+
const plan = [];
|
|
692
|
+
for (const dir of listProfileCacheDirs(from)) {
|
|
693
|
+
const base = path.basename(dir);
|
|
694
|
+
const suffix = base.slice(from.length);
|
|
695
|
+
const dest = path.join(path.dirname(dir), `${to}${suffix}`);
|
|
696
|
+
if (fs.existsSync(dest)) {
|
|
697
|
+
throw new Error(`Cannot rename: ${dest} already exists. Nothing was moved. Remove or rename it first.`);
|
|
698
|
+
}
|
|
699
|
+
plan.push({ dir, dest });
|
|
700
|
+
}
|
|
701
|
+
const movedDirs = [];
|
|
702
|
+
for (const { dir, dest } of plan) {
|
|
703
|
+
fs.renameSync(dir, dest);
|
|
704
|
+
movedDirs.push(dest);
|
|
705
|
+
}
|
|
706
|
+
if (scope === 'local') {
|
|
707
|
+
updateMeta((m) => {
|
|
708
|
+
const next = { ...m.deviceBrowser };
|
|
709
|
+
delete next[from];
|
|
710
|
+
return { ...m, deviceBrowser: { ...next, [to]: config } };
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
updateMeta((m) => {
|
|
715
|
+
const next = { ...m.browser };
|
|
716
|
+
delete next[from];
|
|
717
|
+
return { ...m, browser: { ...next, [to]: config } };
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
// Both pointers are separate keys. Leaving `browser.profile` behind falls back
|
|
721
|
+
// to auto-detect on the next `browser start`; leaving `browser.viewer` behind
|
|
722
|
+
// sends every artifact back to the OS default handler — which is the exact bug
|
|
723
|
+
// the viewer seam was built to fix, reintroduced by a rename.
|
|
724
|
+
const { setConfigValue, getConfigValue } = await import('../device-config.js');
|
|
725
|
+
let repointedDefault = false;
|
|
726
|
+
if (getConfiguredDefaultProfileName() === from) {
|
|
727
|
+
setConfigValue('browser.profile', to);
|
|
728
|
+
repointedDefault = true;
|
|
729
|
+
}
|
|
730
|
+
let repointedViewer = false;
|
|
731
|
+
if (getConfigValue('browser.viewer').value === from) {
|
|
732
|
+
setConfigValue('browser.viewer', to);
|
|
733
|
+
repointedViewer = true;
|
|
734
|
+
}
|
|
735
|
+
// A fleet rename changes the name on every machine, but each machine's own
|
|
736
|
+
// config doc is separate — a peer pinning the old name is left dangling. Not
|
|
737
|
+
// rewritten from here: that would be a cross-machine mutation, and on a peer
|
|
738
|
+
// the pin may be correct (a LOCAL profile of the same name). Reported instead.
|
|
739
|
+
const { devicesPinningBrowserProfile } = await import('../device-config.js');
|
|
740
|
+
const { machineId } = await import('../machine-id.js');
|
|
741
|
+
const stalePins = scope === 'fleet'
|
|
742
|
+
? devicesPinningBrowserProfile(from).filter((p) => p.device !== machineId())
|
|
743
|
+
: [];
|
|
744
|
+
return { scope, movedDirs, repointedDefault, repointedViewer, stalePins };
|
|
745
|
+
}
|
|
746
|
+
export async function moveProfileScope(name, to) {
|
|
747
|
+
const meta = readMeta();
|
|
748
|
+
const local = meta.deviceBrowser?.[name];
|
|
749
|
+
const fleet = meta.browser?.[name];
|
|
750
|
+
if (!local && !fleet) {
|
|
751
|
+
throw new Error(`Profile "${name}" does not exist`);
|
|
752
|
+
}
|
|
753
|
+
const from = local ? 'local' : 'fleet';
|
|
754
|
+
if (from === to)
|
|
755
|
+
return { from, to };
|
|
756
|
+
if (to === 'fleet' && isMachineLocalProfile(name)) {
|
|
757
|
+
throw new Error(`"${name}" is always machine-local — its binary path and port are this box's. ` +
|
|
758
|
+
`Fleet-syncing it is the rewrite loop RUSH-2161 fixed.`);
|
|
759
|
+
}
|
|
760
|
+
const config = (local ?? fleet);
|
|
761
|
+
// Write the destination BEFORE dropping the source, so a crash between the two
|
|
762
|
+
// leaves a duplicate (harmless — local wins in allProfileConfigs) rather than
|
|
763
|
+
// no profile at all.
|
|
764
|
+
if (to === 'local') {
|
|
765
|
+
updateMeta((m) => ({ ...m, deviceBrowser: { ...m.deviceBrowser, [name]: config } }));
|
|
766
|
+
updateMeta((m) => {
|
|
767
|
+
const next = { ...m.browser };
|
|
768
|
+
delete next[name];
|
|
769
|
+
return { ...m, browser: Object.keys(next).length > 0 ? next : undefined };
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
updateMeta((m) => ({ ...m, browser: { ...m.browser, [name]: config } }));
|
|
774
|
+
updateMeta((m) => {
|
|
775
|
+
const next = { ...m.deviceBrowser };
|
|
776
|
+
delete next[name];
|
|
777
|
+
return { ...m, deviceBrowser: Object.keys(next).length > 0 ? next : undefined };
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
return { from, to };
|
|
781
|
+
}
|
|
499
782
|
export async function deleteProfile(name) {
|
|
500
783
|
const meta = readMeta();
|
|
501
784
|
const isLocal = !!meta.deviceBrowser?.[name];
|
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
* `browser.remote-control` config key, never synced).
|
|
10
10
|
*
|
|
11
11
|
* Local invocations (no marker) are never gated — this only governs cross-machine
|
|
12
|
-
* drives. Read-only queries are not gated
|
|
13
|
-
*
|
|
12
|
+
* drives. Read-only queries are not gated.
|
|
13
|
+
*
|
|
14
|
+
* The authoritative gate is {@link assertRemoteControlAllowedForRequest}, called
|
|
15
|
+
* inside the browser daemon at the two points that can OPEN a browser
|
|
16
|
+
* (`BrowserService.start` and the create branch of `resolveOrCreateTask`). It has
|
|
17
|
+
* to live there because ~18 page verbs (`navigate`, `click`, `screenshot`, …)
|
|
18
|
+
* create a browser implicitly, and gating only the `browser start` command left
|
|
19
|
+
* every one of them ungated. {@link assertRemoteControlAllowed} remains as a
|
|
20
|
+
* fast-fail CLI-side check so a refused `start` never auto-creates a profile.
|
|
14
21
|
*/
|
|
15
22
|
/** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
|
|
16
23
|
export declare const FLEET_REMOTE_ENV = "AGENTS_FLEET_REMOTE";
|
|
@@ -33,3 +40,20 @@ export declare function assertRemoteControlAllowed(opts?: {
|
|
|
33
40
|
env?: NodeJS.ProcessEnv;
|
|
34
41
|
enabled?: boolean;
|
|
35
42
|
}): void;
|
|
43
|
+
/**
|
|
44
|
+
* The daemon-side consent gate.
|
|
45
|
+
*
|
|
46
|
+
* Reads ONLY the per-request marker, never `process.env`: the browser daemon is
|
|
47
|
+
* shared and long-lived, and it may have been auto-started by a fleet-remote CLI
|
|
48
|
+
* that leaked `AGENTS_FLEET_REMOTE=1` into its environment permanently. Reading
|
|
49
|
+
* the daemon's env would then refuse every subsequent LOCAL drive on this
|
|
50
|
+
* machine until the daemon restarted.
|
|
51
|
+
*
|
|
52
|
+
* `actor` is likewise the caller's forwarded identity, not the daemon's.
|
|
53
|
+
*/
|
|
54
|
+
export declare function assertRemoteControlAllowedForRequest(fleetRemote: boolean | undefined, opts?: {
|
|
55
|
+
actor?: string;
|
|
56
|
+
enabled?: boolean;
|
|
57
|
+
}): void;
|
|
58
|
+
/** The refusal text, shared by the CLI-side and daemon-side gates. */
|
|
59
|
+
export declare function remoteControlRefusal(who: string): string;
|
|
@@ -9,8 +9,15 @@
|
|
|
9
9
|
* `browser.remote-control` config key, never synced).
|
|
10
10
|
*
|
|
11
11
|
* Local invocations (no marker) are never gated — this only governs cross-machine
|
|
12
|
-
* drives. Read-only queries are not gated
|
|
13
|
-
*
|
|
12
|
+
* drives. Read-only queries are not gated.
|
|
13
|
+
*
|
|
14
|
+
* The authoritative gate is {@link assertRemoteControlAllowedForRequest}, called
|
|
15
|
+
* inside the browser daemon at the two points that can OPEN a browser
|
|
16
|
+
* (`BrowserService.start` and the create branch of `resolveOrCreateTask`). It has
|
|
17
|
+
* to live there because ~18 page verbs (`navigate`, `click`, `screenshot`, …)
|
|
18
|
+
* create a browser implicitly, and gating only the `browser start` command left
|
|
19
|
+
* every one of them ungated. {@link assertRemoteControlAllowed} remains as a
|
|
20
|
+
* fast-fail CLI-side check so a refused `start` never auto-creates a profile.
|
|
14
21
|
*/
|
|
15
22
|
import { getConfigValue } from '../device-config.js';
|
|
16
23
|
/** Env marker set on every remote `agents` invocation by `buildRemoteAgentsInvocation`. */
|
|
@@ -42,7 +49,30 @@ export function assertRemoteControlAllowed(opts) {
|
|
|
42
49
|
if (enabled)
|
|
43
50
|
return;
|
|
44
51
|
const who = env.AGENTS_ACTOR_HOST || env.AGENTS_ACTOR || 'A fleet machine';
|
|
45
|
-
throw new Error(
|
|
52
|
+
throw new Error(remoteControlRefusal(who));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The daemon-side consent gate.
|
|
56
|
+
*
|
|
57
|
+
* Reads ONLY the per-request marker, never `process.env`: the browser daemon is
|
|
58
|
+
* shared and long-lived, and it may have been auto-started by a fleet-remote CLI
|
|
59
|
+
* that leaked `AGENTS_FLEET_REMOTE=1` into its environment permanently. Reading
|
|
60
|
+
* the daemon's env would then refuse every subsequent LOCAL drive on this
|
|
61
|
+
* machine until the daemon restarted.
|
|
62
|
+
*
|
|
63
|
+
* `actor` is likewise the caller's forwarded identity, not the daemon's.
|
|
64
|
+
*/
|
|
65
|
+
export function assertRemoteControlAllowedForRequest(fleetRemote, opts = {}) {
|
|
66
|
+
if (!fleetRemote)
|
|
67
|
+
return;
|
|
68
|
+
const enabled = opts.enabled ?? remoteControlEnabled();
|
|
69
|
+
if (enabled)
|
|
70
|
+
return;
|
|
71
|
+
throw new Error(remoteControlRefusal(opts.actor || 'A fleet machine'));
|
|
72
|
+
}
|
|
73
|
+
/** The refusal text, shared by the CLI-side and daemon-side gates. */
|
|
74
|
+
export function remoteControlRefusal(who) {
|
|
75
|
+
return (`${who} tried to drive this machine's browser over \`browser --device\`, but remote ` +
|
|
46
76
|
`browser control is off here. To allow it, run on THIS machine:\n` +
|
|
47
77
|
` agents browser remote-control on`);
|
|
48
78
|
}
|
|
@@ -148,6 +148,13 @@ export interface PrunePlan {
|
|
|
148
148
|
name: string;
|
|
149
149
|
scope: ProfileScope;
|
|
150
150
|
why: string;
|
|
151
|
+
/**
|
|
152
|
+
* True for a fleet profile whose endpoint only makes sense on one machine.
|
|
153
|
+
* A structured flag, not a substring of `why`: both the CLI's human output
|
|
154
|
+
* and `--json` consumers branch on it, and sniffing the reason text would
|
|
155
|
+
* couple them to wording.
|
|
156
|
+
*/
|
|
157
|
+
misfiled?: boolean;
|
|
151
158
|
}>;
|
|
152
159
|
}
|
|
153
160
|
export declare const PRUNE_REASON_TEXT: Record<PruneReason, string>;
|
|
@@ -182,6 +189,8 @@ export declare function planProfilePrune(profiles: Array<{
|
|
|
182
189
|
name: string;
|
|
183
190
|
scope: ProfileScope;
|
|
184
191
|
launchableHere: boolean;
|
|
192
|
+
/** Set when this fleet profile's endpoint only makes sense on one machine. */
|
|
193
|
+
misfiledWhy?: string;
|
|
185
194
|
}>, opts?: {
|
|
186
195
|
includeFleet?: boolean;
|
|
187
196
|
configuredDefault?: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { execFileSync } from 'child_process';
|
|
4
|
-
import { getProfileRuntimeDir, getBrowserRuntimeDir, listProfilesWithScope, getConfiguredDefaultProfileName, isProfileLaunchableHere, deleteProfile, DEFAULT_BROWSER_PROFILE_NAME, LEGACY_DEFAULT_BROWSER_PROFILE_NAME, } from './profiles.js';
|
|
4
|
+
import { getProfileRuntimeDir, getBrowserRuntimeDir, listProfilesWithScope, getConfiguredDefaultProfileName, isProfileLaunchableHere, deleteProfile, DEFAULT_BROWSER_PROFILE_NAME, LEGACY_DEFAULT_BROWSER_PROFILE_NAME, misfiledFleetProfile, } from './profiles.js';
|
|
5
5
|
import { keyBelongsToProfile } from './types.js';
|
|
6
6
|
const PID_FILE = 'pid';
|
|
7
7
|
const PORT_FILE = 'port';
|
|
@@ -308,8 +308,20 @@ export const PRUNE_REASON_TEXT = {
|
|
|
308
308
|
*/
|
|
309
309
|
export function planProfilePrune(profiles, opts = {}) {
|
|
310
310
|
const plan = { candidates: [], kept: [] };
|
|
311
|
-
for (const { name, scope, launchableHere } of profiles) {
|
|
312
|
-
const keep = (why
|
|
311
|
+
for (const { name, scope, launchableHere, misfiledWhy } of profiles) {
|
|
312
|
+
const keep = (why, misfiled = false) => {
|
|
313
|
+
plan.kept.push(misfiled ? { name, scope, why, misfiled } : { name, scope, why });
|
|
314
|
+
};
|
|
315
|
+
// Checked FIRST, ahead of every other rule including --fleet. Deleting a
|
|
316
|
+
// fleet entry deletes it on EVERY machine, and a misfiled profile's defining
|
|
317
|
+
// symptom is that the browser is absent on whatever box you are standing on
|
|
318
|
+
// — so `prune --fleet` would otherwise read `binary-missing` and destroy it
|
|
319
|
+
// fleet-wide, which is the opposite of the repair. The repair is a scope
|
|
320
|
+
// move; an intentional delete is still available as `profiles delete`.
|
|
321
|
+
if (misfiledWhy) {
|
|
322
|
+
keep(`MISFILED — ${misfiledWhy}`, true);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
313
325
|
if (scope === 'fleet' && !opts.includeFleet) {
|
|
314
326
|
keep('fleet-synced (pass --fleet to include it)');
|
|
315
327
|
continue;
|
|
@@ -345,11 +357,15 @@ export function planProfilePrune(profiles, opts = {}) {
|
|
|
345
357
|
*/
|
|
346
358
|
export async function buildProfilePrunePlan(opts = {}) {
|
|
347
359
|
const scoped = await listProfilesWithScope();
|
|
348
|
-
return planProfilePrune(scoped.map(({ profile, scope }) =>
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
360
|
+
return planProfilePrune(scoped.map(({ profile, scope }) => {
|
|
361
|
+
const misfiled = misfiledFleetProfile(profile, scope);
|
|
362
|
+
return {
|
|
363
|
+
name: profile.name,
|
|
364
|
+
scope,
|
|
365
|
+
launchableHere: isProfileLaunchableHere(profile),
|
|
366
|
+
misfiledWhy: misfiled.misfiled ? misfiled.why : undefined,
|
|
367
|
+
};
|
|
368
|
+
}), { includeFleet: opts.includeFleet, configuredDefault: getConfiguredDefaultProfileName() });
|
|
353
369
|
}
|
|
354
370
|
/**
|
|
355
371
|
* Execute a prune plan: drop each candidate's config entry and wipe its cache
|
|
@@ -161,6 +161,8 @@ export declare class BrowserService {
|
|
|
161
161
|
launchId?: string;
|
|
162
162
|
/** Calling agent session, forwarded from the CLI (see IPCRequest.sessionId). */
|
|
163
163
|
sessionId?: string;
|
|
164
|
+
/** Whether the CALLER was dispatched here by a fleet `--device` hop. */
|
|
165
|
+
fleetRemote?: boolean;
|
|
164
166
|
/** Explicit human label (`--title`). */
|
|
165
167
|
title?: string;
|
|
166
168
|
}): Promise<{
|
|
@@ -469,6 +471,8 @@ export declare class BrowserService {
|
|
|
469
471
|
* - no handle, zero, !createIfMissing → return null (caller reports "nothing to close")
|
|
470
472
|
*/
|
|
471
473
|
resolveOrCreateTask(opts: {
|
|
474
|
+
/** Whether the CALLER was dispatched here by a fleet `--device` hop. */
|
|
475
|
+
fleetRemote?: boolean;
|
|
472
476
|
task?: string;
|
|
473
477
|
profile?: string;
|
|
474
478
|
actor?: string;
|
|
@@ -536,6 +540,39 @@ export declare class BrowserService {
|
|
|
536
540
|
private saveTaskState;
|
|
537
541
|
private loadTaskState;
|
|
538
542
|
private saveToHistory;
|
|
543
|
+
/**
|
|
544
|
+
* The cached connection for `key`, or undefined — evicting it first if the
|
|
545
|
+
* browser died underneath us.
|
|
546
|
+
*
|
|
547
|
+
* A browser killed externally (Cmd-Q, crash, a user clearing the profile by
|
|
548
|
+
* hand) leaves a closed WebSocket in the map. Without this the next
|
|
549
|
+
* `cdp.send` throws "CDP connection not open" and there is no recovery short
|
|
550
|
+
* of killing the daemon.
|
|
551
|
+
*/
|
|
552
|
+
private reuseHealthyConnection;
|
|
553
|
+
/** Connect a profile at `target`, register it under `key`, and arm downloads. */
|
|
554
|
+
private openConnection;
|
|
555
|
+
/**
|
|
556
|
+
* Open a tab for a HUMAN to read, bound to no task.
|
|
557
|
+
*
|
|
558
|
+
* That is the entire difference from {@link start}, and it is the point. The
|
|
559
|
+
* abandoned-task reaper closes a task's tabs when the calling session dies or
|
|
560
|
+
* after the idle window, but it "deliberately never touches a tab that is not
|
|
561
|
+
* in `task.tabs`" (hygiene.ts) — so a viewer tab must not be in one, or the
|
|
562
|
+
* artifact the user is reading vanishes when the agent that rendered it exits.
|
|
563
|
+
*
|
|
564
|
+
* Consequently `stop`/`done` do not close it either, which is correct: it is
|
|
565
|
+
* the user's tab now.
|
|
566
|
+
*/
|
|
567
|
+
showUrl(profileName: ProfileName, url: string, opts?: {
|
|
568
|
+
endpointName?: string;
|
|
569
|
+
fleetRemote?: boolean;
|
|
570
|
+
actor?: string;
|
|
571
|
+
}): Promise<{
|
|
572
|
+
profile: ProfileName;
|
|
573
|
+
key: ConnectionKey;
|
|
574
|
+
tabId: string;
|
|
575
|
+
}>;
|
|
539
576
|
getHistory(limit?: number): Promise<HistoricalTask[]>;
|
|
540
577
|
}
|
|
541
578
|
export {};
|