@phnx-labs/agents-cli 1.22.117 → 1.22.119

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.
Files changed (271) hide show
  1. package/CHANGELOG.md +301 -0
  2. package/dist/bootstrap.js +4 -11
  3. package/dist/commands/accounts.js +7 -4
  4. package/dist/commands/browser.d.ts +52 -70
  5. package/dist/commands/browser.js +192 -3562
  6. package/dist/commands/daemon.d.ts +39 -2
  7. package/dist/commands/daemon.js +72 -73
  8. package/dist/commands/events.js +1 -1
  9. package/dist/commands/exec.js +53 -1
  10. package/dist/commands/projects.js +92 -7
  11. package/dist/commands/repo.js +13 -49
  12. package/dist/commands/route.js +5 -5
  13. package/dist/commands/routines.js +39 -12
  14. package/dist/commands/sessions-render.d.ts +1 -1
  15. package/dist/commands/sessions-render.js +0 -1
  16. package/dist/commands/sessions.test-fixture.d.ts +0 -1
  17. package/dist/commands/sessions.test-fixture.js +0 -37
  18. package/dist/commands/setup-browser.d.ts +17 -12
  19. package/dist/commands/setup-browser.js +52 -87
  20. package/dist/commands/setup-preferences.d.ts +0 -17
  21. package/dist/commands/setup-preferences.js +18 -49
  22. package/dist/commands/setup.js +9 -9
  23. package/dist/commands/ssh.js +10 -1
  24. package/dist/commands/sync.js +6 -44
  25. package/dist/commands/teams.js +0 -1
  26. package/dist/commands/update.d.ts +19 -0
  27. package/dist/commands/update.js +34 -6
  28. package/dist/commands/view.d.ts +12 -1
  29. package/dist/commands/view.js +47 -2
  30. package/dist/index.js +107 -26
  31. package/dist/lib/account-capabilities.js +0 -2
  32. package/dist/lib/account-catalog.d.ts +38 -0
  33. package/dist/lib/account-catalog.js +110 -26
  34. package/dist/lib/account-provider-registry.js +1 -1
  35. package/dist/lib/account-registry.d.ts +1 -1
  36. package/dist/lib/account-registry.js +36 -2
  37. package/dist/lib/accounting/rotate.js +22 -8
  38. package/dist/lib/accounting/usage-ingest.d.ts +6 -1
  39. package/dist/lib/accounting/usage-ingest.js +115 -45
  40. package/dist/lib/accounting/usage-sync.d.ts +134 -31
  41. package/dist/lib/accounting/usage-sync.js +227 -66
  42. package/dist/lib/accounting/usage.d.ts +16 -3
  43. package/dist/lib/accounting/usage.js +26 -8
  44. package/dist/lib/accounts/add.d.ts +3 -1
  45. package/dist/lib/accounts/add.js +24 -13
  46. package/dist/lib/accounts/slots.d.ts +8 -0
  47. package/dist/lib/accounts/slots.js +17 -0
  48. package/dist/lib/add-dir.js +0 -1
  49. package/dist/lib/agent-cli-commands.js +0 -1
  50. package/dist/lib/agent-spec/agents.d.ts +6 -7
  51. package/dist/lib/agent-spec/agents.js +7 -60
  52. package/dist/lib/auth-health.d.ts +69 -1
  53. package/dist/lib/auth-health.js +133 -4
  54. package/dist/lib/browser/context.d.ts +82 -0
  55. package/dist/lib/browser/context.js +88 -0
  56. package/dist/lib/browser/paths.d.ts +25 -0
  57. package/dist/lib/browser/paths.js +51 -0
  58. package/dist/lib/browser/record.d.ts +38 -0
  59. package/dist/lib/browser/record.js +57 -0
  60. package/dist/lib/browser/sessions-list.js +1 -2
  61. package/dist/lib/browser-client.d.ts +158 -0
  62. package/dist/lib/browser-client.js +234 -0
  63. package/dist/lib/channels/owner-sink.d.ts +1 -18
  64. package/dist/lib/channels/owner-sink.js +29 -82
  65. package/dist/lib/channels/providers/rush.d.ts +7 -7
  66. package/dist/lib/channels/providers/rush.js +127 -57
  67. package/dist/lib/claude-account-token.d.ts +7 -1
  68. package/dist/lib/claude-account-token.js +26 -4
  69. package/dist/lib/claude-statusline.d.ts +1 -1
  70. package/dist/lib/claude-statusline.js +15 -3
  71. package/dist/lib/commands.js +1 -1
  72. package/dist/lib/crabbox/lease.js +1 -1
  73. package/dist/lib/crabbox/runtimes.js +1 -3
  74. package/dist/lib/daemon/account-state-daemon-service.js +1 -0
  75. package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
  76. package/dist/lib/daemon/auth-sync-service.js +37 -43
  77. package/dist/lib/daemon/daemon.d.ts +25 -14
  78. package/dist/lib/daemon/daemon.js +92 -154
  79. package/dist/lib/daemon/runner.d.ts +2 -2
  80. package/dist/lib/daemon/runner.js +32 -9
  81. package/dist/lib/daemon/self-update-service.d.ts +35 -34
  82. package/dist/lib/daemon/self-update-service.js +40 -36
  83. package/dist/lib/daemon/service.d.ts +12 -8
  84. package/dist/lib/daemon/supervisor.d.ts +65 -54
  85. package/dist/lib/daemon/supervisor.js +136 -171
  86. package/dist/lib/daemon/testdata/process-view-start.js +5 -2
  87. package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
  88. package/dist/lib/daemon/usage-sync-service.js +46 -78
  89. package/dist/lib/daemon-health.d.ts +22 -5
  90. package/dist/lib/daemon-health.js +47 -3
  91. package/dist/lib/daemon-services.d.ts +1 -1
  92. package/dist/lib/daemon-services.js +0 -10
  93. package/dist/lib/daemon-ticks.js +4 -2
  94. package/dist/lib/devices/doctor-findings.js +8 -11
  95. package/dist/lib/devices/harness-inventory.js +14 -5
  96. package/dist/lib/exec-account-home.js +5 -0
  97. package/dist/lib/exec.js +10 -13
  98. package/dist/lib/feed/events.js +0 -1
  99. package/dist/lib/feed/tool-activity.js +5 -2
  100. package/dist/lib/fleet-shared-state.d.ts +45 -0
  101. package/dist/lib/fleet-shared-state.js +78 -9
  102. package/dist/lib/fs-atomic.js +25 -0
  103. package/dist/lib/github/pr-mergeable.d.ts +4 -2
  104. package/dist/lib/github/pr-mergeable.js +5 -3
  105. package/dist/lib/github/project-prs.d.ts +58 -3
  106. package/dist/lib/github/project-prs.js +205 -7
  107. package/dist/lib/harness/adapters/cursor.js +14 -4
  108. package/dist/lib/harness-auth-capabilities.d.ts +4 -3
  109. package/dist/lib/harness-auth-capabilities.js +1 -2
  110. package/dist/lib/helper-versions.js +1 -1
  111. package/dist/lib/hooks/cache.d.ts +7 -0
  112. package/dist/lib/hooks/cache.js +27 -2
  113. package/dist/lib/hooks/install.js +7 -1
  114. package/dist/lib/hosts/ready.d.ts +38 -8
  115. package/dist/lib/hosts/ready.js +62 -14
  116. package/dist/lib/installations/active-check.d.ts +27 -0
  117. package/dist/lib/installations/active-check.js +61 -0
  118. package/dist/lib/installations/migrate.js +6 -3
  119. package/dist/lib/installations/shims.d.ts +1 -1
  120. package/dist/lib/installations/shims.js +32 -12
  121. package/dist/lib/installations/store.d.ts +9 -0
  122. package/dist/lib/installations/store.js +24 -0
  123. package/dist/lib/installations/update.js +21 -15
  124. package/dist/lib/installations/versions.js +15 -3
  125. package/dist/lib/manifest.js +1 -1
  126. package/dist/lib/models.js +0 -2
  127. package/dist/lib/open-url.js +28 -54
  128. package/dist/lib/profiles.js +0 -2
  129. package/dist/lib/projects.d.ts +16 -0
  130. package/dist/lib/projects.js +31 -0
  131. package/dist/lib/refresh.js +1 -1
  132. package/dist/lib/routine-readiness.d.ts +35 -0
  133. package/dist/lib/routine-readiness.js +74 -16
  134. package/dist/lib/rules/compile.js +6 -0
  135. package/dist/lib/sandbox.js +0 -2
  136. package/dist/lib/secrets-policy.d.ts +89 -11
  137. package/dist/lib/secrets-policy.js +175 -28
  138. package/dist/lib/session/active.d.ts +17 -6
  139. package/dist/lib/session/active.js +105 -49
  140. package/dist/lib/session/db.d.ts +1 -1
  141. package/dist/lib/session/db.js +65 -18
  142. package/dist/lib/session/discover.js +1 -283
  143. package/dist/lib/session/mirror.d.ts +4 -3
  144. package/dist/lib/session/mirror.js +4 -3
  145. package/dist/lib/session/parse.d.ts +0 -2
  146. package/dist/lib/session/parse.js +1 -138
  147. package/dist/lib/session/pid-registry.d.ts +6 -3
  148. package/dist/lib/session/pid-registry.js +18 -8
  149. package/dist/lib/session/remote/watch.d.ts +1 -1
  150. package/dist/lib/session/remote/watch.js +7 -3
  151. package/dist/lib/session/throughput.d.ts +1 -3
  152. package/dist/lib/session/throughput.js +0 -20
  153. package/dist/lib/session/types.d.ts +2 -2
  154. package/dist/lib/session/types.js +2 -2
  155. package/dist/lib/sessions-client.d.ts +60 -1
  156. package/dist/lib/sessions-client.js +227 -4
  157. package/dist/lib/setup-tool-status.js +5 -10
  158. package/dist/lib/signin-badge.d.ts +3 -2
  159. package/dist/lib/signin-badge.js +6 -3
  160. package/dist/lib/smart-launch.js +4 -1
  161. package/dist/lib/staleness/detectors/commands.d.ts +1 -1
  162. package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
  163. package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
  164. package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
  165. package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
  166. package/dist/lib/staleness/detectors/rules.d.ts +1 -1
  167. package/dist/lib/staleness/detectors/skills.d.ts +1 -1
  168. package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
  169. package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
  170. package/dist/lib/staleness/writers/commands.d.ts +1 -1
  171. package/dist/lib/staleness/writers/hooks.d.ts +1 -1
  172. package/dist/lib/staleness/writers/mcp.d.ts +1 -1
  173. package/dist/lib/staleness/writers/permissions.d.ts +1 -1
  174. package/dist/lib/staleness/writers/plugins.d.ts +1 -1
  175. package/dist/lib/staleness/writers/rules.d.ts +1 -1
  176. package/dist/lib/staleness/writers/skills.d.ts +1 -1
  177. package/dist/lib/staleness/writers/sources.js +5 -0
  178. package/dist/lib/staleness/writers/subagents.d.ts +1 -1
  179. package/dist/lib/staleness/writers/workflows.d.ts +1 -1
  180. package/dist/lib/state.d.ts +6 -4
  181. package/dist/lib/state.js +7 -17
  182. package/dist/lib/teams/parsers.d.ts +1 -1
  183. package/dist/lib/teams/parsers.js +1 -136
  184. package/dist/lib/teams/placement-probe.js +50 -15
  185. package/dist/lib/teams/scheduler.d.ts +7 -0
  186. package/dist/lib/types.d.ts +16 -1
  187. package/dist/lib/types.js +1 -1
  188. package/dist/lib/view-types.d.ts +32 -0
  189. package/dist/lib/watchdog/read.js +0 -1
  190. package/dist/lib/watchdog/rotate.js +1 -2
  191. package/dist/lib/watchdog/runner.js +2 -2
  192. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  193. package/dist/lib/watchdog/watchdogTail.js +0 -31
  194. package/package.json +1 -1
  195. package/scripts/postinstall.js +8 -8
  196. package/dist/browser.d.ts +0 -2
  197. package/dist/browser.js +0 -17
  198. package/dist/commands/browser-picker.d.ts +0 -18
  199. package/dist/commands/browser-picker.js +0 -97
  200. package/dist/lib/browser/arc-discovery.d.ts +0 -52
  201. package/dist/lib/browser/arc-discovery.js +0 -197
  202. package/dist/lib/browser/arc-dom.d.ts +0 -14
  203. package/dist/lib/browser/arc-dom.js +0 -121
  204. package/dist/lib/browser/caller-identity.d.ts +0 -40
  205. package/dist/lib/browser/caller-identity.js +0 -175
  206. package/dist/lib/browser/cdp.d.ts +0 -51
  207. package/dist/lib/browser/cdp.js +0 -254
  208. package/dist/lib/browser/chrome.d.ts +0 -141
  209. package/dist/lib/browser/chrome.js +0 -704
  210. package/dist/lib/browser/chromium-discovery.d.ts +0 -28
  211. package/dist/lib/browser/chromium-discovery.js +0 -96
  212. package/dist/lib/browser/devices.d.ts +0 -34
  213. package/dist/lib/browser/devices.js +0 -61
  214. package/dist/lib/browser/domain-skills.d.ts +0 -71
  215. package/dist/lib/browser/domain-skills.js +0 -195
  216. package/dist/lib/browser/drivers/arc.d.ts +0 -40
  217. package/dist/lib/browser/drivers/arc.js +0 -276
  218. package/dist/lib/browser/drivers/firefox.d.ts +0 -99
  219. package/dist/lib/browser/drivers/firefox.js +0 -377
  220. package/dist/lib/browser/drivers/local.d.ts +0 -55
  221. package/dist/lib/browser/drivers/local.js +0 -266
  222. package/dist/lib/browser/drivers/ssh.d.ts +0 -120
  223. package/dist/lib/browser/drivers/ssh.js +0 -467
  224. package/dist/lib/browser/editor.d.ts +0 -3
  225. package/dist/lib/browser/editor.js +0 -50
  226. package/dist/lib/browser/ffmpeg.d.ts +0 -12
  227. package/dist/lib/browser/ffmpeg.js +0 -184
  228. package/dist/lib/browser/firefox-discovery.d.ts +0 -69
  229. package/dist/lib/browser/firefox-discovery.js +0 -162
  230. package/dist/lib/browser/har.d.ts +0 -85
  231. package/dist/lib/browser/har.js +0 -77
  232. package/dist/lib/browser/hygiene.d.ts +0 -97
  233. package/dist/lib/browser/hygiene.js +0 -153
  234. package/dist/lib/browser/index.d.ts +0 -5
  235. package/dist/lib/browser/index.js +0 -5
  236. package/dist/lib/browser/input.d.ts +0 -7
  237. package/dist/lib/browser/input.js +0 -92
  238. package/dist/lib/browser/ipc.d.ts +0 -204
  239. package/dist/lib/browser/ipc.js +0 -1453
  240. package/dist/lib/browser/login-detection.d.ts +0 -87
  241. package/dist/lib/browser/login-detection.js +0 -274
  242. package/dist/lib/browser/profiles.d.ts +0 -422
  243. package/dist/lib/browser/profiles.js +0 -1157
  244. package/dist/lib/browser/refs.d.ts +0 -89
  245. package/dist/lib/browser/refs.js +0 -191
  246. package/dist/lib/browser/registry.d.ts +0 -70
  247. package/dist/lib/browser/registry.js +0 -252
  248. package/dist/lib/browser/remote-control.d.ts +0 -59
  249. package/dist/lib/browser/remote-control.js +0 -85
  250. package/dist/lib/browser/resolve-target.d.ts +0 -66
  251. package/dist/lib/browser/resolve-target.js +0 -264
  252. package/dist/lib/browser/runtime-state.d.ts +0 -291
  253. package/dist/lib/browser/runtime-state.js +0 -584
  254. package/dist/lib/browser/secret-ref.d.ts +0 -10
  255. package/dist/lib/browser/secret-ref.js +0 -14
  256. package/dist/lib/browser/service.d.ts +0 -812
  257. package/dist/lib/browser/service.js +0 -4693
  258. package/dist/lib/browser/stream.d.ts +0 -17
  259. package/dist/lib/browser/stream.js +0 -72
  260. package/dist/lib/browser/task-index.d.ts +0 -78
  261. package/dist/lib/browser/task-index.js +0 -199
  262. package/dist/lib/browser/types.d.ts +0 -647
  263. package/dist/lib/browser/types.js +0 -61
  264. package/dist/lib/browser/upload.d.ts +0 -29
  265. package/dist/lib/browser/upload.js +0 -298
  266. package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
  267. package/dist/lib/daemon/browser-ipc-service.js +0 -46
  268. package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
  269. package/dist/lib/daemon/browser-task-reap-service.js +0 -26
  270. package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
  271. package/dist/lib/fleet-shared-repo-sync.js +0 -490
@@ -1,1157 +0,0 @@
1
- import * as path from 'path';
2
- import * as fs from 'fs';
3
- import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, getBrowserDurableDir, readMeta, updateMeta, } from '../state.js';
4
- import { getConfigValue } from '../device-config.js';
5
- import { machineId } from '../machine-id.js';
6
- import { declaringDevices, profileRegistry, } from './registry.js';
7
- import { findBrowserPath, isPortInUse } from './chrome.js';
8
- import { arcSpaceProfiles, discoverArcProfiles } from './arc-discovery.js';
9
- import { discoverChromiumProfiles, discoverableChromiumBrowsers } from './chromium-discovery.js';
10
- import { firefoxDiscoveredProfiles, discoverFirefoxProfiles } from './firefox-discovery.js';
11
- export { declaringDevices, profileKind, profileRegistry, } from './registry.js';
12
- /**
13
- * Name of the profile the setup wizards pin as this machine's default browser
14
- * (`agents setup`, `agents setup browser`). Older builds also auto-created it
15
- * silently on the first `agents browser start`; PHNX-3296 removed that — see
16
- * {@link ensureDefaultBrowserProfile}.
17
- *
18
- * It is `auto-chrome`, NOT `default`, since RUSH-2709: `default` used to be
19
- * both this concrete profile AND the alias meaning "whatever profile the user
20
- * configured", so `--profile default` landed on a literal Chrome on one command
21
- * and on the user's configured Comet on another. The alias now lives alone in
22
- * {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one place
23
- * ({@link resolveProfileRef}).
24
- */
25
- export const DEFAULT_BROWSER_PROFILE_NAME = 'auto-chrome';
26
- /**
27
- * What older builds named the auto-detected profile. Still honored everywhere:
28
- * a user whose machine already carries a `default` profile keeps using it (and
29
- * its running browser + runtime dirs) rather than having a second one created
30
- * beside it.
31
- */
32
- export const LEGACY_DEFAULT_BROWSER_PROFILE_NAME = 'default';
33
- /**
34
- * The reserved ALIAS. `--profile default` (and a `browser.profile` config value
35
- * of `default`) means "the configured default profile", not a profile called
36
- * `default` — unless a profile literally named `default` exists, which still
37
- * wins, so no existing config breaks.
38
- */
39
- export const DEFAULT_PROFILE_ALIAS = 'default';
40
- /**
41
- * The device-local configured default profile name (set via
42
- * `agents browser use`), or undefined when unset. When set, it
43
- * is the profile `agents browser start` resolves to for BOTH the no-`--profile`
44
- * path and an explicit `--profile default`. Stored as this machine's
45
- * `browser.profile` device-config key (the per-device doc's `config:` block —
46
- * see lib/device-config.ts).
47
- */
48
- export function getConfiguredDefaultProfileName() {
49
- return getConfigValue('browser.profile').value || undefined;
50
- }
51
- export function getBrowserRuntimeDir() {
52
- return getBrowserRuntimeDirRoot();
53
- }
54
- export function getProfileRuntimeDir(name) {
55
- return path.join(getBrowserRuntimeDir(), name);
56
- }
57
- /**
58
- * True when a profile attaches to a browser the user already launched and MUST
59
- * NOT spawn its own (PHNX-3967). Arc is inherently attach-only — it is
60
- * single-instance and relaunching it just produces a stray window, never a
61
- * second debuggable process (issue #2779) — regardless of an explicit policy.
62
- * Any other browser is attach-only only when the profile says so.
63
- */
64
- export function isAttachOnlyProfile(profile) {
65
- return profile.browser === 'arc' || profile.launchPolicy === 'attach-only';
66
- }
67
- /**
68
- * The DURABLE `--user-data-dir` for a profile (PHNX-3967). An explicit
69
- * `profile.userDataDir` wins; otherwise a per-profile dir under
70
- * `~/.agents/.history/browser-profiles/<name>/chrome-data`, which is outside
71
- * `~/.agents/.cache` so a one-time sign-in survives quit+relaunch and the
72
- * `profiles remove` cache sweep. This is the dir the canonical Comet is launched
73
- * with and the value the ownership guard compares a running instance against.
74
- *
75
- * Note this is distinct from {@link getProfileRuntimeDir}'s cache `chrome-data`,
76
- * which `launchBrowser` uses for a `launch`-policy profile it spawns itself.
77
- */
78
- export function resolveProfileDataDir(profile) {
79
- if (profile.userDataDir)
80
- return profile.userDataDir;
81
- return path.join(getBrowserDurableDir(), profile.name, 'chrome-data');
82
- }
83
- /**
84
- * Normalize a `--user-data-dir` path for the attach-only ownership comparison
85
- * (PHNX-3967): the real path when it resolves on this box, else the literal with
86
- * trailing slashes stripped. The single source both the runtime guard
87
- * (`drivers/local.ts` `verifyEndpointOwnership`) and `browser profiles doctor`
88
- * use, so they can never disagree about whether a running instance is foreign.
89
- */
90
- export function normalizeDataDir(dir) {
91
- let out = dir;
92
- try {
93
- out = fs.realpathSync(dir);
94
- }
95
- catch {
96
- /* dir may not exist on this box; compare the literal */
97
- }
98
- return out.replace(/\/+$/, '');
99
- }
100
- /**
101
- * Default destination for browser downloads for a profile. Set browser-global at
102
- * connect time (see BrowserService), so downloads land here even when the agent
103
- * never calls `browser download --path`. Keyed by the same composite name as the
104
- * runtime dir, so every profile is one self-contained tree.
105
- */
106
- export function getProfileDownloadsDir(name) {
107
- return path.join(getProfileRuntimeDir(name), 'downloads');
108
- }
109
- /**
110
- * Per-profile directory for a task's captured artifacts (screenshots, PDFs,
111
- * recordings). Replaces the legacy global `browser/sessions/<task>/` root — the
112
- * one-shot migration in migrate.ts folds old captures into here.
113
- */
114
- export function getProfileSessionsDir(name, task) {
115
- return path.join(getProfileRuntimeDir(name), 'sessions', task);
116
- }
117
- function configToProfile(name, config, devices = []) {
118
- validateRemoteBrowserBinaries(config);
119
- return {
120
- name,
121
- description: config.description,
122
- browser: config.browser,
123
- binary: config.binary,
124
- electron: config.electron,
125
- targetFilter: config.targetFilter,
126
- endpoints: config.endpoints,
127
- defaultEndpoint: config.defaultEndpoint,
128
- launchPolicy: config.launchPolicy,
129
- userDataDir: config.userDataDir,
130
- profileDirectory: config.profileDirectory,
131
- chrome: config.chrome,
132
- secrets: config.secrets,
133
- viewport: config.viewport,
134
- logDir: config.logDir,
135
- logHost: config.logHost,
136
- arc: config.arc,
137
- firefox: config.firefox,
138
- devices,
139
- };
140
- }
141
- function profileToConfig(profile) {
142
- validateRemoteBrowserBinaries(profile);
143
- const config = {
144
- browser: profile.browser,
145
- endpoints: profile.endpoints,
146
- };
147
- if (profile.description)
148
- config.description = profile.description;
149
- if (profile.binary)
150
- config.binary = profile.binary;
151
- if (profile.electron)
152
- config.electron = profile.electron;
153
- if (profile.targetFilter)
154
- config.targetFilter = profile.targetFilter;
155
- if (profile.defaultEndpoint)
156
- config.defaultEndpoint = profile.defaultEndpoint;
157
- if (profile.launchPolicy)
158
- config.launchPolicy = profile.launchPolicy;
159
- if (profile.userDataDir)
160
- config.userDataDir = profile.userDataDir;
161
- if (profile.profileDirectory)
162
- config.profileDirectory = profile.profileDirectory;
163
- if (profile.chrome)
164
- config.chrome = profile.chrome;
165
- if (profile.secrets)
166
- config.secrets = profile.secrets;
167
- if (profile.viewport)
168
- config.viewport = profile.viewport;
169
- if (profile.logDir)
170
- config.logDir = profile.logDir;
171
- if (profile.logHost)
172
- config.logHost = profile.logHost;
173
- if (profile.arc)
174
- config.arc = profile.arc;
175
- if (profile.firefox)
176
- config.firefox = profile.firefox;
177
- return config;
178
- }
179
- export function discoverNativeProfiles() {
180
- const profiles = [];
181
- const errors = {};
182
- const arc = discoverArcProfiles();
183
- if (arc.ok) {
184
- for (const space of arcSpaceProfiles(arc)) {
185
- profiles.push({
186
- name: space.name,
187
- description: `Arc Space "${space.spaceTitle}" (${space.profileName})`,
188
- browser: 'arc',
189
- endpoints: { native: { target: 'arc-native://local' } },
190
- defaultEndpoint: 'native',
191
- launchPolicy: 'attach-only',
192
- arc: {
193
- profileId: space.profileId,
194
- profileName: space.profileName,
195
- spaceId: space.spaceId,
196
- spaceTitle: space.spaceTitle,
197
- },
198
- devices: [machineId()],
199
- });
200
- }
201
- }
202
- else if (arc.kind === 'invalid') {
203
- errors.arc = `Cannot discover Arc Spaces: ${arc.reason}`;
204
- }
205
- // Ports handed out earlier in this pass: two unpublished profiles must never
206
- // derive the same port before either is declared.
207
- const reserved = new Set();
208
- for (const browser of discoverableChromiumBrowsers()) {
209
- const found = discoverChromiumProfiles(browser);
210
- if (!found.ok) {
211
- if (found.kind === 'invalid')
212
- errors[browser] = `Cannot discover ${browser} profiles: ${found.reason}`;
213
- continue;
214
- }
215
- for (const native of found.profiles) {
216
- const port = nativeChromiumPort(native.name, reserved);
217
- reserved.add(port);
218
- profiles.push({
219
- name: native.name,
220
- description: `${browser} profile "${native.displayName}" (${native.profileDirectory})`,
221
- browser,
222
- endpoints: [`cdp://127.0.0.1:${port}`],
223
- userDataDir: native.userDataDir,
224
- profileDirectory: native.profileDirectory,
225
- devices: [machineId()],
226
- });
227
- }
228
- }
229
- // Firefox (PHNX-4043): one profiles.ini entry is one `firefox-<slug>` profile,
230
- // pinned to that directory and a stable WebDriver BiDi port. Firefox dropped
231
- // CDP in 129, so these use a `firefox-bidi:` endpoint, not `cdp://`.
232
- const firefox = discoverFirefoxProfiles();
233
- if (firefox.ok) {
234
- for (const entry of firefoxDiscoveredProfiles(firefox)) {
235
- profiles.push({
236
- name: entry.name,
237
- description: `Firefox profile "${entry.profileName}"${entry.isDefault ? ' (default)' : ''}`,
238
- browser: 'firefox',
239
- endpoints: { bidi: { target: `firefox-bidi://127.0.0.1:${entry.port}` } },
240
- defaultEndpoint: 'bidi',
241
- userDataDir: entry.profileDir,
242
- firefox: {
243
- profileName: entry.profileName,
244
- iniPath: entry.iniPath,
245
- isDefault: entry.isDefault,
246
- },
247
- devices: [machineId()],
248
- });
249
- }
250
- }
251
- else if (firefox.kind === 'invalid') {
252
- errors.firefox = `Cannot discover Firefox profiles: ${firefox.reason}`;
253
- }
254
- return { profiles, errors };
255
- }
256
- /**
257
- * The CDP port a discovered Chromium profile attaches on. Once the profile is
258
- * declared its port lives in the declaration; before that, the port is the
259
- * first free one from the canonical Comet port (9333, PHNX-3967), stepping
260
- * past ports other local declarations hold and past `reserved`, the ports
261
- * already given to sibling profiles in the same discovery pass.
262
- */
263
- function nativeChromiumPort(name, reserved) {
264
- const declared = localDeclaration(name)?.config;
265
- const declaredPort = declared ? parseEndpointPort(declared) : undefined;
266
- if (declaredPort)
267
- return declaredPort;
268
- const taken = new Set(reserved);
269
- for (const [other, declarations] of profileRegistry()) {
270
- if (other === name)
271
- continue;
272
- for (const declaration of declarations) {
273
- if (declaration.device !== machineId())
274
- continue;
275
- const port = parseEndpointPort(declaration.config);
276
- if (port)
277
- taken.add(port);
278
- }
279
- }
280
- let port = 9333;
281
- while (taken.has(port))
282
- port += 1;
283
- return port;
284
- }
285
- function parseEndpointPort(config) {
286
- const endpoints = config.endpoints;
287
- const first = Array.isArray(endpoints)
288
- ? endpoints[0]
289
- : endpoints && typeof endpoints === 'object'
290
- ? Object.values(endpoints)[0]?.target
291
- : undefined;
292
- if (typeof first !== 'string')
293
- return undefined;
294
- const match = /^cdp:\/\/[^:/?]+(?::(\d+)|\?port=(\d+))/.exec(first);
295
- const raw = match?.[1] ?? match?.[2];
296
- return raw ? Number(raw) : undefined;
297
- }
298
- /**
299
- * Publish every discovered native profile that this device has not declared
300
- * yet into its device declaration (`~/.agents/devices/<device>/agents.yaml`),
301
- * which the fleet shared-store sync carries to every other box (PHNX-4042).
302
- * That is what lets `agents browser profiles list` on a worker show this
303
- * Mac's `arc-*` and `comet-*` rows with WHERE=<this device>, and a start from
304
- * there route here. Idempotent; a discovery error skips that browser.
305
- */
306
- export async function publishDiscoveredProfiles() {
307
- const discovered = discoverNativeProfiles();
308
- const published = [];
309
- for (const profile of discovered.profiles) {
310
- if (localDeclaration(profile.name))
311
- continue;
312
- await createProfile(profile);
313
- published.push(profile.name);
314
- }
315
- return { published, errors: discovered.errors };
316
- }
317
- /**
318
- * Bring a persisted discovered profile up to date with its live source (an Arc
319
- * Space title rename, a Comet profile rename). A source that is no longer
320
- * discoverable leaves the declaration as stored: listing still works, and using
321
- * the profile fails loud where the browser is actually reached.
322
- */
323
- function refreshDiscoveredProfile(profile, live) {
324
- if (!profile.devices.includes(machineId()))
325
- return profile;
326
- if (profile.arc) {
327
- const current = live.find((candidate) => candidate.arc?.spaceId === profile.arc?.spaceId);
328
- if (!current)
329
- return profile;
330
- return { ...profile, arc: current.arc, description: profile.description ?? current.description };
331
- }
332
- if (profile.userDataDir && profile.profileDirectory) {
333
- const current = live.find((candidate) => candidate.userDataDir === profile.userDataDir && candidate.profileDirectory === profile.profileDirectory);
334
- if (!current)
335
- return profile;
336
- return { ...profile, description: profile.description ?? current.description };
337
- }
338
- return profile;
339
- }
340
- function selectedDeclaration(declarations) {
341
- return declarations.find((declaration) => declaration.device === machineId()) ?? declarations[0];
342
- }
343
- function localDeclaration(name) {
344
- return profileRegistry().get(name)?.find((declaration) => declaration.device === machineId());
345
- }
346
- /** Whether this name has persisted agents-cli metadata on the current device. */
347
- export function isProfileDeclaredHere(name) {
348
- return localDeclaration(name) !== undefined;
349
- }
350
- export async function listProfiles() {
351
- const native = discoverNativeProfiles();
352
- const configured = [...profileRegistry()].map(([name, declarations]) => {
353
- const selected = selectedDeclaration(declarations);
354
- return refreshDiscoveredProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
355
- });
356
- const names = new Set(configured.map((profile) => profile.name));
357
- return [...configured, ...native.profiles.filter((profile) => !names.has(profile.name))];
358
- }
359
- export async function getProfile(name) {
360
- const native = discoverNativeProfiles();
361
- const declarations = profileRegistry().get(name);
362
- if (declarations?.length) {
363
- const selected = selectedDeclaration(declarations);
364
- return refreshDiscoveredProfile(configToProfile(name, selected.config, declarations.map((declaration) => declaration.device)), native.profiles);
365
- }
366
- const discovered = native.profiles.find((profile) => profile.name === name) ?? null;
367
- // Only a profile of that browser asked for by name pays for its failed read.
368
- const prefix = name.split('-')[0];
369
- if (!discovered && native.errors[prefix])
370
- throw new Error(native.errors[prefix]);
371
- return discovered;
372
- }
373
- /** Persist only the agents-cli alias for an auto-discovered native profile; never the browser's own data. */
374
- export async function persistDiscoveredProfile(name) {
375
- if (localDeclaration(name))
376
- return;
377
- const profile = discoverNativeProfiles().profiles.find((candidate) => candidate.name === name);
378
- if (!profile)
379
- return;
380
- await createProfile(profile);
381
- }
382
- /**
383
- * True when `profile` can actually launch or attach on THIS machine.
384
- *
385
- * A remote (`ssh://`) profile runs its browser on the far host, so a local
386
- * binary check doesn't apply — assume launchable. A local profile must have its
387
- * configured browser/binary present on disk here; a `default` profile
388
- * auto-created on a different OS (e.g. a macOS Chrome path reused on Linux — the
389
- * "Custom binary not found" failure) fails this check so the caller re-detects
390
- * for the current machine.
391
- */
392
- export function isProfileLaunchableHere(profile) {
393
- const nativeArc = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('arc-native:'));
394
- if (nativeArc) {
395
- return !!profile.arc && discoverNativeProfiles().profiles.some((candidate) => candidate.arc?.spaceId === profile.arc?.spaceId);
396
- }
397
- const bidi = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('firefox-bidi:'));
398
- if (bidi) {
399
- // Launchable here only when Firefox is installed AND this box holds the
400
- // pinned profile directory (a synced-in firefox- profile from another box
401
- // has that box's path, so it command-dispatches instead).
402
- if (!profile.userDataDir || !fs.existsSync(profile.userDataDir))
403
- return false;
404
- try {
405
- findBrowserPath('firefox', profile.binary);
406
- return true;
407
- }
408
- catch {
409
- return false;
410
- }
411
- }
412
- const remote = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('ssh://'));
413
- if (remote)
414
- return true;
415
- try {
416
- findBrowserPath(profile.browser, profile.binary);
417
- return true;
418
- }
419
- catch {
420
- return false;
421
- }
422
- }
423
- /**
424
- * The auto-detected profile this machine already carries, under either name —
425
- * the current `auto-chrome` or the `default` an older build wrote. Callers that
426
- * ask "is a default profile set up here?" MUST use this rather than probing one
427
- * name, or a machine that predates the RUSH-2709 rename grows a second one.
428
- */
429
- export async function getAutoDetectedProfile() {
430
- const current = machineId();
431
- const registry = profileRegistry();
432
- for (const name of [DEFAULT_BROWSER_PROFILE_NAME, LEGACY_DEFAULT_BROWSER_PROFILE_NAME]) {
433
- const declaration = registry.get(name)?.find((entry) => entry.device === current);
434
- if (declaration)
435
- return configToProfile(name, declaration.config, [current]);
436
- }
437
- return null;
438
- }
439
- /**
440
- * Resolve what a caller typed after `--profile` to a real, BARE profile name.
441
- * Every command — stop, status, navigate, tab, use, and (through
442
- * {@link resolveProfileRefForStart}) start — routes through this one function,
443
- * which is the whole point: before RUSH-2709 only `start` honored the reserved
444
- * `default` alias, so `--profile default` meant three different profiles across
445
- * three commands.
446
- *
447
- * Order:
448
- * 1. A profile that literally bears the given name always wins — including
449
- * one a user genuinely named `default`, which resolves to itself.
450
- * 2. `default` (the alias) or no argument at all → the configured
451
- * `browser.profile` for this machine, if it names a profile that exists.
452
- * 3. → the auto-detected profile ({@link DEFAULT_BROWSER_PROFILE_NAME}), or
453
- * its legacy `default`-named predecessor when that is what is on disk.
454
- * 4. Otherwise the input is returned unchanged (so the caller reports its own
455
- * `Profile "x" not found`), or `undefined` when nothing was passed.
456
- *
457
- * It never creates a profile, never warns, and never checks whether the profile
458
- * can launch HERE. That is deliberate: for most callers `--profile` is a FILTER
459
- * (`status`, `tasks`, `navigate --task`), where an absent flag means "no filter"
460
- * and rewriting or warning about the user's config would be wrong. Note the
461
- * corollary at those call sites — pass a ref only when the user gave one; do not
462
- * resolve `undefined` into a concrete profile, or an unscoped listing silently
463
- * becomes a scoped one.
464
- */
465
- export async function resolveProfileRef(ref) {
466
- if (ref && ref !== DEFAULT_PROFILE_ALIAS) {
467
- return ref;
468
- }
469
- if (ref === DEFAULT_PROFILE_ALIAS && (await getProfile(ref))) {
470
- // A profile the user literally named `default` outranks the alias.
471
- return ref;
472
- }
473
- const configured = getConfiguredDefaultProfileName();
474
- if (configured && configured !== DEFAULT_PROFILE_ALIAS && (await getProfile(configured))) {
475
- return configured;
476
- }
477
- if (await getProfile(DEFAULT_BROWSER_PROFILE_NAME))
478
- return DEFAULT_BROWSER_PROFILE_NAME;
479
- if (await getProfile(LEGACY_DEFAULT_BROWSER_PROFILE_NAME)) {
480
- return LEGACY_DEFAULT_BROWSER_PROFILE_NAME;
481
- }
482
- return ref;
483
- }
484
- /**
485
- * `start`'s resolver. Same order as {@link resolveProfileRef} for an explicit
486
- * name, but the IMPLICIT path (no `--profile`, or the bare `default` alias with
487
- * no profile of that name) goes through {@link ensureDefaultBrowserProfile} —
488
- * which additionally verifies the resolved default can launch on THIS machine.
489
- * An undeclared configured default is an error. A declared default whose
490
- * browser isn't installed here warns and falls through to an existing profile,
491
- * else the actionable throw below.
492
- *
493
- * `start` is the only command that launches a browser, so it is the only one
494
- * that may do those things; routing a filter-only command through this would
495
- * warn about config the user never asked it to touch.
496
- *
497
- * Throws ({@link noDefaultBrowserError}) when the configured default is
498
- * undeclared, or when no profile exists that can launch here.
499
- */
500
- export async function resolveProfileRefForStart(ref) {
501
- if (ref && ref !== DEFAULT_PROFILE_ALIAS)
502
- return ref;
503
- // A profile the user literally named `default` still outranks the alias.
504
- if (ref === DEFAULT_PROFILE_ALIAS && (await getProfile(ref)))
505
- return ref;
506
- return (await ensureDefaultBrowserProfile()).name;
507
- }
508
- /**
509
- * The error a bare `agents browser start` raises when this machine has no
510
- * launchable default browser. Its own function so the wording — the one thing
511
- * the user reads when a browser won't start — stays in one place and is
512
- * testable without spawning anything.
513
- *
514
- * Since PHNX-3296 this is a hard stop, NOT a silent auto-create. The old
515
- * behavior probed the installed Chromium-family browsers and minted a
516
- * logged-out `auto-chrome` profile on the spot; agents then drove a signed-out
517
- * Chrome that popped up on the user's Mac unbidden. Which browser agents drive
518
- * is a choice the user makes once, in `agents setup` — never one this code
519
- * makes for them.
520
- */
521
- export function noDefaultBrowserError() {
522
- return new Error('No default browser is configured on this machine. ' +
523
- 'Run `agents setup` (or `agents browser use <name>`) to pick the browser agents should drive. ' +
524
- 'If this is a headless worker, use the fleet hub instead: `agents config set browser.device <host>`.');
525
- }
526
- /**
527
- * Resolve the profile a bare `agents browser start` uses.
528
- *
529
- * Order: (1) the device-local configured default (`agents browser use <name>`)
530
- * when it names a profile that exists and can launch here; (2) an existing
531
- * auto-detected profile (`auto-chrome`, or a legacy `default`) that can launch
532
- * here. When neither resolves, THROW ({@link noDefaultBrowserError}) rather than
533
- * detect-and-create — see that function for why (PHNX-3296).
534
- *
535
- * Two failure modes at the configured-default step are not the same:
536
- * - No device declares the name (including a leftover central `browser:`
537
- * entry that was never claimed) → throw. Falling back to a minted profile
538
- * would hand the agent a logged-out browser while `browser.profile` still
539
- * names the credentialed one.
540
- * - The name is declared, but its browser/binary is not installed HERE →
541
- * warn and fall through to an existing profile, else the actionable throw.
542
- * That is a missing binary on this box, not a missing identity.
543
- *
544
- * This RECOGNIZES a pre-existing `auto-chrome`/legacy `default` so installs that
545
- * already carry one keep resolving it (and its running browser + runtime dirs),
546
- * but it never CREATES one.
547
- */
548
- export async function ensureDefaultBrowserProfile() {
549
- const configured = getConfiguredDefaultProfileName();
550
- if (configured && configured !== DEFAULT_PROFILE_ALIAS) {
551
- const chosen = await getProfile(configured);
552
- if (chosen && (!chosen.devices.includes(machineId()) || isProfileLaunchableHere(chosen)))
553
- return chosen;
554
- if (!chosen) {
555
- const central = readMeta().browser ?? {};
556
- if (central[configured]) {
557
- throw new Error(`configured default browser profile "${configured}" is not declared by any device. ` +
558
- `It still lives in the central agents.yaml browser: map. ` +
559
- `Claim it on the machine that hosts that browser with: agents browser profiles claim ${configured}`);
560
- }
561
- throw new Error(`configured default browser profile "${configured}" is not declared by any device. ` +
562
- `Create it with: agents browser profiles create ${configured} --browser <chrome|comet|chromium|brave|edge|arc|custom> ` +
563
- `(or, if it is a leftover central profile, claim it with: agents browser profiles claim ${configured}). ` +
564
- `Or unset the default with: agents browser use --unset`);
565
- }
566
- console.warn(`warning: configured default browser profile "${configured}" can't launch on this ` +
567
- `machine (its browser/binary isn't installed here). ` +
568
- `Fix with: agents browser use <name> (or --unset)`);
569
- }
570
- // Prefer whichever auto-detected profile this machine already carries: the
571
- // current `auto-chrome`, else the `default` an older build wrote. Reusing the
572
- // legacy one is what keeps a currently-running browser (and its runtime dirs,
573
- // keyed `default@endpoint-0`) attached after the rename.
574
- const existing = await getAutoDetectedProfile();
575
- if (existing && isProfileLaunchableHere(existing))
576
- return existing;
577
- // No configured default resolves and no existing profile launches here. We
578
- // used to auto-detect the first installed browser and silently mint (or
579
- // regenerate) an `auto-chrome` profile at this point — that is exactly the
580
- // logged-out-Chrome-on-your-Mac bug PHNX-3296 removed. Stop and tell the user
581
- // how to choose a browser instead.
582
- throw noDefaultBrowserError();
583
- }
584
- /**
585
- * Compute the LOCAL port a profile will occupy at runtime:
586
- * - `cdp://127.0.0.1:N` → N (we listen on N directly)
587
- * - `ssh://host?port=N` → N (the SSH tunnel binds local N → remote N now)
588
- * - `ws[s]://`, `http[s]://` → undefined (we don't claim a local port)
589
- *
590
- * This is what callers should compare to detect collisions; the (host,
591
- * port) tuple is no longer enough because SSH profiles do compete with
592
- * cdp:// profiles for local ports under the new tunnel scheme.
593
- */
594
- export function effectiveLocalPort(profile) {
595
- const presets = getEndpointPresets(profile);
596
- const firstName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
597
- ? profile.defaultEndpoint
598
- : Object.keys(presets)[0];
599
- if (!firstName)
600
- return undefined;
601
- const target = presets[firstName].target;
602
- let url;
603
- try {
604
- url = new URL(target);
605
- }
606
- catch {
607
- return undefined;
608
- }
609
- if (url.protocol !== 'cdp:' && url.protocol !== 'ssh:')
610
- return undefined;
611
- return parseEndpointUrl(target)?.port;
612
- }
613
- /**
614
- * Find a port in 9222–9399 that is not already claimed by ANY existing
615
- * profile (cdp:// or ssh://) and is not in use by any OS process. The
616
- * SSH change to bind locally on `?port=N` means we no longer get to
617
- * skip remote profiles in this scan.
618
- */
619
- export async function findFreeProfilePort() {
620
- const profiles = await listProfiles();
621
- const usedByProfile = new Set();
622
- for (const p of profiles) {
623
- const port = effectiveLocalPort(p);
624
- if (port !== undefined)
625
- usedByProfile.add(port);
626
- }
627
- for (let port = 9222; port <= 9399; port++) {
628
- if (usedByProfile.has(port))
629
- continue;
630
- // Platform-aware bound-port probe (lsof on POSIX, netstat on Windows).
631
- // The old inline lsof call threw ENOENT on Windows — where lsof doesn't
632
- // exist — so EVERY port scanned as "free", including one an already-
633
- // running browser was listening on (typically 9222), and the new profile
634
- // would silently attach to that browser instead of launching its own.
635
- if (!isPortInUse(port))
636
- return port;
637
- }
638
- throw new Error('No available ports in range 9222-9399');
639
- }
640
- function validateRemoteBrowserBinaries(profile) {
641
- if (!hasSshEndpoint(profile.endpoints))
642
- return;
643
- validateRemoteBrowserBinary(profile.binary);
644
- if (!Array.isArray(profile.endpoints)) {
645
- for (const preset of Object.values(profile.endpoints)) {
646
- validateRemoteBrowserBinary(preset.binary);
647
- }
648
- }
649
- }
650
- function validateRemoteBrowserBinary(binary) {
651
- if (!binary)
652
- return;
653
- if (/[\0\r\n;&|`$<>]/.test(binary)) {
654
- throw new Error(`Remote browser binary contains shell metacharacters: ${binary}`);
655
- }
656
- }
657
- export function hasSshEndpoint(endpoints) {
658
- const targets = Array.isArray(endpoints)
659
- ? endpoints
660
- : Object.values(endpoints).map((preset) => preset.target);
661
- return targets.some((target) => {
662
- try {
663
- return new URL(target).protocol === 'ssh:';
664
- }
665
- catch {
666
- return false;
667
- }
668
- });
669
- }
670
- /**
671
- * Whether the automatic central-tombstone drain (PHNX-3315) may claim `config`
672
- * into THIS device's doc during `agents sync`.
673
- *
674
- * Only REMOTE (`ssh://`) profiles qualify. An `ssh://` endpoint names a specific
675
- * host, so the profile is fungible by design — any box resolves it to the same
676
- * browser, and a concurrent double-claim across machines is harmless. A
677
- * local/`cdp://` profile has NO per-machine ownership signal: "that browser is
678
- * installed here" is not "I hold this profile's credentialed session", so two
679
- * boxes with the same common browser installed would each auto-claim the same
680
- * tombstone on their first post-merge sync and flip it identity->fungible
681
- * fleet-wide (the exact logged-out-browser failure this module exists to prevent
682
- * — PHNX-3315 review). Local/cdp tombstones stay central for the explicit
683
- * `agents browser profiles claim`.
684
- */
685
- export function shouldAutoClaimCentralProfile(config) {
686
- if (!hasSshEndpoint(config.endpoints))
687
- return false;
688
- return isProfileLaunchableHere({
689
- name: '_',
690
- browser: config.browser,
691
- binary: config.binary,
692
- endpoints: config.endpoints,
693
- });
694
- }
695
- /**
696
- * Refuse a profile whose LOCAL port another profile already owns.
697
- *
698
- * Every CDP/SSH profile ends up listening on (or tunneling to) the same LOCAL
699
- * port number as the one configured in the endpoint URL — SSH profiles reuse
700
- * `?port=N` locally, so we no longer scope by host. Two profiles that would
701
- * need the same local port can't both run at the same time.
702
- *
703
- * `opts.ignore` is the profile's own name on an edit. Without it every edit
704
- * collides with itself, since the stored copy still owns the port being kept.
705
- */
706
- export function assertLocalPortFree(profile, opts = {}) {
707
- const wanted = effectiveLocalPort(profile);
708
- if (wanted === undefined)
709
- return;
710
- for (const [existingName, declarations] of profileRegistry()) {
711
- if (existingName === opts.ignore)
712
- continue;
713
- const declaration = declarations.find((entry) => entry.device === machineId());
714
- if (!declaration)
715
- continue;
716
- const existingProfile = configToProfile(existingName, declaration.config, [machineId()]);
717
- if (effectiveLocalPort(existingProfile) === wanted) {
718
- throw new Error(`Local port ${wanted} is already used by profile "${existingName}". ` +
719
- `Each profile must own a unique local port (SSH tunnels now bind ` +
720
- `to their configured port locally too). Pick a different port.`);
721
- }
722
- }
723
- }
724
- /** Declare a profile on this device. The same name may be declared by peers. */
725
- export async function createProfile(profile) {
726
- if (localDeclaration(profile.name)) {
727
- throw new Error(`Profile "${profile.name}" already exists on ${machineId()}`);
728
- }
729
- assertLocalPortFree(profile);
730
- // Resolve the browser binary at create time. Fails fast with an actionable
731
- // error ("Comet not installed at /Applications/Comet.app") rather than
732
- // deferring the failure to the first task. `findBrowserPath` short-circuits
733
- // for browser=custom without a binary by throwing — same outcome.
734
- //
735
- // Skip for SSH profiles: the browser binary lives on the remote host, so a
736
- // local lookup would validate the wrong machine. The remote launcher resolves
737
- // it at connect time.
738
- const nativeArc = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('arc-native:'));
739
- if (nativeArc && (!profile.arc || profile.browser !== 'arc')) {
740
- throw new Error('An arc-native profile requires stable Arc Space metadata. Pick a discovered one from `agents browser profiles list`.');
741
- }
742
- const firefoxBidi = Object.values(getEndpointPresets(profile)).some((preset) => preset.target.startsWith('firefox-bidi:'));
743
- if (firefoxBidi && (!profile.firefox || profile.browser !== 'firefox')) {
744
- throw new Error('A firefox-bidi profile requires a discovered Firefox profile. Pick one from `agents browser profiles list`.');
745
- }
746
- // A discovered profile is pinned to a browser store on this disk
747
- // (`userDataDir` + `profileDirectory`) and is published unattended by the
748
- // daemon tick. Its binary is resolved where it is launched (`launchBrowser`),
749
- // so an app that was removed after its store fails loud at `start` instead of
750
- // blocking every other discovered row from publishing.
751
- const storePinned = !!profile.userDataDir && !!profile.profileDirectory;
752
- if (!hasSshEndpoint(profile.endpoints) && !nativeArc && !storePinned) {
753
- findBrowserPath(profile.browser, profile.binary);
754
- }
755
- const config = profileToConfig(profile);
756
- updateMeta((meta) => ({
757
- ...meta,
758
- deviceBrowser: { ...meta.deviceBrowser, [profile.name]: config },
759
- }));
760
- }
761
- export async function updateProfile(profile) {
762
- const local = localDeclaration(profile.name);
763
- if (!local) {
764
- const devices = declaringDevices(profile.name);
765
- const where = devices.length > 0 ? `; declared on ${devices.join(', ')}` : '';
766
- throw new Error(`Profile "${profile.name}" is not declared on ${machineId()}${where}`);
767
- }
768
- const config = profileToConfig(profile);
769
- updateMeta((meta) => ({
770
- ...meta,
771
- deviceBrowser: { ...meta.deviceBrowser, [profile.name]: config },
772
- }));
773
- }
774
- /**
775
- * Merge `patch` onto the stored profile and persist it in the store it already
776
- * lives in (see {@link updateProfile} for the scope rules).
777
- *
778
- * Runs {@link createProfile}'s validations against the MERGED record, not just
779
- * the patched fields — a binary edit re-resolves the browser path, a
780
- * targetFilter edit re-checks the electron gate, and the local-port scan runs
781
- * with this profile excluded so an unchanged port is not a self-collision.
782
- */
783
- export async function editProfile(name, patch) {
784
- const local = localDeclaration(name);
785
- if (!local) {
786
- const devices = declaringDevices(name);
787
- const where = devices.length > 0 ? `; declared on ${devices.join(', ')}` : '';
788
- throw new Error(`Profile "${name}" is not declared on ${machineId()}${where}`);
789
- }
790
- const devices = declaringDevices(name);
791
- const current = configToProfile(name, local.config, devices);
792
- const merged = { ...current, ...patch, name };
793
- const changed = Object.keys(patch).filter((k) => JSON.stringify(current[k]) !== JSON.stringify(merged[k]));
794
- // A target filter only means anything for a profile that reuses an existing
795
- // page target rather than creating one — an Electron app or Arc (which crashes
796
- // on tab creation, so it drives an open tab / Space, PHNX-2399). The gate must
797
- // read the MERGED record: `--target-filter x` on a profile that is already
798
- // electron/arc is valid, and `--no-electron` on a Chromium profile that still
799
- // carries a filter is not. Checking the patch alone would accept both.
800
- if (merged.targetFilter && !merged.electron && merged.browser !== 'arc') {
801
- throw new Error(`--target-filter only applies to an Electron or Arc profile. ` +
802
- `Pass --electron, use --browser arc, or clear the filter with --target-filter ''.`);
803
- }
804
- assertLocalPortFree(merged, { ignore: name });
805
- // Same rule as create: the binary lives on the remote for an SSH profile, so a
806
- // local lookup would validate the wrong machine.
807
- const nativeArc = Object.values(getEndpointPresets(merged)).some((preset) => preset.target.startsWith('arc-native:'));
808
- if (!hasSshEndpoint(merged.endpoints) && !nativeArc) {
809
- findBrowserPath(merged.browser, merged.binary);
810
- }
811
- await updateProfile(merged);
812
- return {
813
- profile: merged,
814
- devices,
815
- changed,
816
- };
817
- }
818
- /** Profile names an agent has to be able to type and an fs path can hold. */
819
- const PROFILE_NAME_RE = /^[a-z][a-z0-9-]*$/;
820
- /**
821
- * Throw if `name` cannot be used for a NEW profile.
822
- *
823
- * Shared by `profiles create` and {@link renameProfile} so the two cannot drift
824
- * — the shape rule used to live inline in the command, which is how `rename`
825
- * would have accepted a name `create` rejects.
826
- *
827
- * `default` is refused because it is the reserved ALIAS meaning "whatever
828
- * profile this machine is configured to use" (RUSH-2709). A literal profile by
829
- * that name makes `--profile default` mean two different things.
830
- */
831
- export function assertRegistrableProfileName(name) {
832
- if (!PROFILE_NAME_RE.test(name)) {
833
- throw new Error(`Invalid profile name ${JSON.stringify(name)}. Use lowercase letters, digits and hyphens, ` +
834
- `starting with a letter — e.g. 'agents'.`);
835
- }
836
- if (name === DEFAULT_PROFILE_ALIAS) {
837
- throw new Error(`"${DEFAULT_PROFILE_ALIAS}" is the reserved alias for this machine's configured profile, ` +
838
- `not a profile name. Pick another name.`);
839
- }
840
- // `browser.viewer: os` means "use the OS default handler". A profile by that
841
- // name turns the opt-out into a pointer at a profile — silently, since both
842
- // are just strings in the same key.
843
- if (name === 'os') {
844
- throw new Error(`"os" is the reserved browser.viewer value meaning the OS default handler, ` +
845
- `not a profile name. Pick another name.`);
846
- }
847
- }
848
- /**
849
- * Rename a profile, taking its on-disk state with it.
850
- *
851
- * `editProfile` deliberately refuses a name change, and this is why: the name
852
- * keys the runtime dir ({@link getProfileRuntimeDir}), every fork/endpoint dir
853
- * derived from it, and the `browser.profile` pointer. Delete-and-recreate — the
854
- * only route before this — silently abandons the browser's `--user-data-dir`,
855
- * which is where a profile's logins live. On a real agent browser that is
856
- * gigabytes of session state and every account it has ever signed into.
857
- *
858
- * Refuses when the profile is in use: moving a `--user-data-dir` out from under
859
- * a running browser corrupts it.
860
- */
861
- export async function renameProfile(from, to) {
862
- if (from === to)
863
- throw new Error(`"${from}" is already its own name.`);
864
- const local = localDeclaration(from);
865
- if (!local) {
866
- const devices = declaringDevices(from);
867
- const where = devices.length > 0 ? `; declared on ${devices.join(', ')}` : '';
868
- throw new Error(`Profile "${from}" is not declared on ${machineId()}${where}`);
869
- }
870
- if (profileRegistry().has(to))
871
- throw new Error(`Profile "${to}" already exists`);
872
- assertRegistrableProfileName(to);
873
- const { isProfileInUse, listProfileCacheDirs } = await import('./runtime-state.js');
874
- if (isProfileInUse(from)) {
875
- throw new Error(`"${from}" is in use (live browser, tunnel, or open task). Renaming would move its ` +
876
- `user-data-dir out from under the running browser. Stop it first: agents browser stop --profile ${from}`);
877
- }
878
- const config = local.config;
879
- const devices = declaringDevices(from);
880
- // Move the on-disk state BEFORE the config, so a crash between the two leaves
881
- // a profile whose dirs are already where the new name expects them rather than
882
- // a config pointing at dirs that no longer exist.
883
- // PRE-FLIGHT every destination before moving ANY of them. With the check
884
- // inside the loop, dir N was validated only after dirs 0..N-1 had already
885
- // moved: a collision on the second endpoint left the first one's logins
886
- // stranded under a name with no config entry, and the error named only the
887
- // squatter. Nothing repaired that, and the user was never told.
888
- const plan = [];
889
- for (const dir of listProfileCacheDirs(from)) {
890
- const base = path.basename(dir);
891
- const suffix = base.slice(from.length);
892
- const dest = path.join(path.dirname(dir), `${to}${suffix}`);
893
- if (fs.existsSync(dest)) {
894
- throw new Error(`Cannot rename: ${dest} already exists. Nothing was moved. Remove or rename it first.`);
895
- }
896
- plan.push({ dir, dest });
897
- }
898
- const movedDirs = [];
899
- for (const { dir, dest } of plan) {
900
- fs.renameSync(dir, dest);
901
- movedDirs.push(dest);
902
- }
903
- updateMeta((meta) => {
904
- const next = { ...meta.deviceBrowser };
905
- delete next[from];
906
- return { ...meta, deviceBrowser: { ...next, [to]: config } };
907
- });
908
- // Both pointers are separate keys. Leaving `browser.profile` behind falls back
909
- // to auto-detect on the next `browser start`; leaving `browser.viewer` behind
910
- // sends every artifact back to the OS default handler — which is the exact bug
911
- // the viewer seam was built to fix, reintroduced by a rename.
912
- const { setConfigValue, getConfigValue } = await import('../device-config.js');
913
- let repointedDefault = false;
914
- if (getConfiguredDefaultProfileName() === from) {
915
- setConfigValue('browser.profile', to);
916
- repointedDefault = true;
917
- }
918
- let repointedViewer = false;
919
- if (getConfigValue('browser.viewer').value === from) {
920
- setConfigValue('browser.viewer', to);
921
- repointedViewer = true;
922
- }
923
- // A rename changes only this device's declaration. If it was the only
924
- // declaration, peer pins become stale and are reported rather than rewritten.
925
- const { devicesPinningBrowserProfile } = await import('../device-config.js');
926
- const stalePins = devices.length === 1
927
- ? devicesPinningBrowserProfile(from).filter((p) => p.device !== machineId())
928
- : [];
929
- return { devices, movedDirs, repointedDefault, repointedViewer, stalePins };
930
- }
931
- export async function deleteProfile(name) {
932
- if (!localDeclaration(name)) {
933
- const devices = declaringDevices(name);
934
- const where = devices.length > 0 ? `; declared on ${devices.join(', ')}` : '';
935
- throw new Error(`Profile "${name}" is not declared on ${machineId()}${where}`);
936
- }
937
- updateMeta((meta) => {
938
- const next = { ...meta.deviceBrowser };
939
- delete next[name];
940
- return { ...meta, deviceBrowser: Object.keys(next).length > 0 ? next : undefined };
941
- });
942
- }
943
- /** Widest NAME column we will grow to before truncating instead. */
944
- const MAX_NAME_COLUMN = 28;
945
- /**
946
- * Pad `text` to exactly `width` visible characters, truncating with an ellipsis
947
- * when it does not fit. `padEnd` alone does NOT bound a long value — it returns
948
- * the string unchanged when it already exceeds the width, which is what pushed
949
- * every later column out of alignment on a name longer than 20 characters
950
- * (RUSH-2710). Every column here goes through this, never a bare `padEnd`.
951
- */
952
- export function padColumn(text, width) {
953
- if (width <= 0)
954
- return '';
955
- if (text.length <= width)
956
- return text.padEnd(width);
957
- if (width === 1)
958
- return '…';
959
- return text.slice(0, width - 1) + '…';
960
- }
961
- /**
962
- * Render the `agents browser profiles list` table.
963
- *
964
- * Pure (no fs, no config reads) so the alignment and the default-marking rules
965
- * are unit-testable without a configured machine.
966
- *
967
- * Two things used to be called "default" in this listing with no way to tell
968
- * them apart (RUSH-2710): the profile literally NAMED `default` (the
969
- * auto-detected one), and whichever profile this machine resolves a bare
970
- * `agents browser start` to (`agents browser use <name>`). They
971
- * are frequently different profiles. Now only the second is a marker — a `*` in
972
- * a leading column, explained by a legend line — so the name column carries just
973
- * the name and `default` in it always means the profile of that name.
974
- *
975
- * WHERE lists the devices whose own files declare the name.
976
- */
977
- export function formatProfilesTable(rows, configuredDefault) {
978
- const cells = rows.map((profile) => {
979
- const presets = getEndpointPresets(profile);
980
- const endpoints = Object.entries(presets)
981
- .map(([name, ep]) => (name.startsWith('endpoint-') ? ep.target : `${name}=${ep.target}`))
982
- .join(', ');
983
- return {
984
- marker: profile.name === configuredDefault ? '*' : ' ',
985
- name: profile.name,
986
- browser: profile.browser || '-',
987
- where: profile.devices.join(', '),
988
- description: profile.description ?? '',
989
- endpoints,
990
- };
991
- });
992
- const nameWidth = Math.min(MAX_NAME_COLUMN, Math.max('NAME'.length, ...cells.map((c) => c.name.length)));
993
- const browserWidth = Math.max('BROWSER'.length, ...cells.map((c) => c.browser.length));
994
- const whereWidth = Math.min(36, Math.max('WHERE'.length, ...cells.map((c) => c.where.length)));
995
- const hasDescriptions = cells.some((c) => c.description);
996
- const descWidth = hasDescriptions
997
- ? Math.min(36, Math.max('DESCRIPTION'.length, ...cells.map((c) => c.description.length)))
998
- : 0;
999
- const row = (marker, name, browser, where, desc, endpoints) => `${marker} ` +
1000
- padColumn(name, nameWidth) + ' ' +
1001
- padColumn(browser, browserWidth) + ' ' +
1002
- padColumn(where, whereWidth) + ' ' +
1003
- (hasDescriptions ? padColumn(desc, descWidth) + ' ' : '') +
1004
- endpoints;
1005
- const header = row(' ', 'NAME', 'BROWSER', 'WHERE', 'DESCRIPTION', 'ENDPOINTS');
1006
- const lines = [header, '-'.repeat(header.length)];
1007
- for (const c of cells) {
1008
- lines.push(row(c.marker, c.name, c.browser, c.where, c.description, c.endpoints));
1009
- }
1010
- if (configuredDefault) {
1011
- lines.push('');
1012
- lines.push(`* = this machine's default profile (${configuredDefault}) — what a bare \`agents browser start\` uses.`);
1013
- }
1014
- return lines;
1015
- }
1016
- /**
1017
- * Resolve a profile's endpoint presets into a normalized map regardless of
1018
- * whether the YAML uses the legacy `string[]` shape or the new map shape.
1019
- * The legacy entries get auto-named `endpoint-0`, `endpoint-1`, ... .
1020
- */
1021
- export function getEndpointPresets(profile) {
1022
- if (Array.isArray(profile.endpoints)) {
1023
- const out = {};
1024
- profile.endpoints.forEach((target, i) => {
1025
- out[`endpoint-${i}`] = { target };
1026
- });
1027
- return out;
1028
- }
1029
- return profile.endpoints;
1030
- }
1031
- /**
1032
- * Pick the endpoint preset to use. Order:
1033
- * 1. Explicit name passed in (errors if unknown)
1034
- * 2. `profile.defaultEndpoint` if set
1035
- * 3. First entry (preserves legacy string[] behavior)
1036
- *
1037
- * Returns the resolved name + the preset (with per-endpoint overrides
1038
- * already applied to binary / targetFilter), so callers don't have to
1039
- * remember the precedence rules.
1040
- */
1041
- export function resolveEndpoint(profile, endpointName) {
1042
- const presets = getEndpointPresets(profile);
1043
- const names = Object.keys(presets);
1044
- if (names.length === 0) {
1045
- throw new Error(`Profile "${profile.name}" has no endpoints configured`);
1046
- }
1047
- let chosenName;
1048
- if (endpointName) {
1049
- if (!presets[endpointName]) {
1050
- throw new Error(`Endpoint "${endpointName}" not found on profile "${profile.name}". ` +
1051
- `Available: ${names.join(', ')}`);
1052
- }
1053
- chosenName = endpointName;
1054
- }
1055
- else if (profile.defaultEndpoint && presets[profile.defaultEndpoint]) {
1056
- chosenName = profile.defaultEndpoint;
1057
- }
1058
- else {
1059
- chosenName = names[0];
1060
- }
1061
- const preset = presets[chosenName];
1062
- return {
1063
- name: chosenName,
1064
- target: preset.target,
1065
- binary: preset.binary ?? profile.binary,
1066
- targetFilter: preset.targetFilter ?? profile.targetFilter,
1067
- };
1068
- }
1069
- /**
1070
- * Extract the (host, port) pair intended by the profile's default endpoint.
1071
- * Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
1072
- *
1073
- * Ports are scoped by host: a `cdp://127.0.0.1:9222` profile (local Chrome on
1074
- * this machine) and an `ssh://remote-host:9222` profile (Comet on a remote
1075
- * host) point at different physical ports — the host disambiguates them.
1076
- *
1077
- * Accepts both `scheme://host:port` and `scheme://host?port=N` shapes (the
1078
- * latter is the documented form in `types.ts` for `ssh://`). Without this,
1079
- * `ssh://remote-host?port=18805` would silently fall back to 9222 and every
1080
- * `?port=`-style SSH profile would collide on creation.
1081
- */
1082
- export function extractConfiguredEndpoint(profile) {
1083
- const presets = getEndpointPresets(profile);
1084
- const firstName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
1085
- ? profile.defaultEndpoint
1086
- : Object.keys(presets)[0];
1087
- if (!firstName)
1088
- return undefined;
1089
- return parseEndpointUrl(presets[firstName].target);
1090
- }
1091
- /**
1092
- * Shared endpoint parser used by both the collision-detection code path and
1093
- * the connection drivers. Returning a single normalized `(host, port)` here
1094
- * keeps `extractConfiguredEndpoint` and the SSH driver from drifting on URL
1095
- * conventions (which is how `?port=N` ended up being silently ignored).
1096
- */
1097
- export function parseEndpointUrl(endpoint) {
1098
- let url;
1099
- try {
1100
- url = new URL(endpoint);
1101
- }
1102
- catch {
1103
- return undefined;
1104
- }
1105
- const host = normalizeHost(url.hostname, url.protocol);
1106
- if (!host)
1107
- return undefined;
1108
- const port = extractPortFromUrl(url);
1109
- if (port !== undefined)
1110
- return { host, port };
1111
- // SSH endpoints tunnel to a remote port AND bind that same port locally,
1112
- // so they do "own" a local port — the host-scoped collision check used
1113
- // to disagree, but we want the local-port-scoped semantics now.
1114
- if (url.protocol === 'cdp:' || url.protocol === 'ssh:')
1115
- return { host, port: 9222 };
1116
- return undefined;
1117
- }
1118
- function extractPortFromUrl(url) {
1119
- if (url.port) {
1120
- const n = parseInt(url.port, 10);
1121
- if (Number.isFinite(n))
1122
- return n;
1123
- }
1124
- // `scheme://host?port=N` — the form documented for SSH endpoints in
1125
- // `types.ts`. WHATWG URL parsing surfaces it via searchParams only.
1126
- const qp = url.searchParams.get('port');
1127
- if (qp) {
1128
- const n = parseInt(qp, 10);
1129
- if (Number.isFinite(n))
1130
- return n;
1131
- }
1132
- return undefined;
1133
- }
1134
- /**
1135
- * Extract the port intended by the profile's default endpoint.
1136
- * Returns undefined for endpoint shapes that don't carry a port (e.g. ws:// without one).
1137
- *
1138
- * Note: this loses the host dimension — for collision detection use
1139
- * `extractConfiguredEndpoint` instead, which returns the (host, port) pair.
1140
- */
1141
- export function extractConfiguredPort(profile) {
1142
- return extractConfiguredEndpoint(profile)?.port;
1143
- }
1144
- function normalizeHost(hostname, protocol) {
1145
- if (!hostname) {
1146
- // cdp:// and ssh:// without an explicit host imply localhost.
1147
- if (protocol === 'cdp:' || protocol === 'ssh:')
1148
- return '127.0.0.1';
1149
- return undefined;
1150
- }
1151
- if (hostname === 'localhost')
1152
- return '127.0.0.1';
1153
- return hostname;
1154
- }
1155
- export function isLocalHost(host) {
1156
- return host === '127.0.0.1' || host === 'localhost' || host === '::1';
1157
- }