@phnx-labs/agents-cli 1.20.50 → 1.20.52

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 (124) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/browser.js +215 -7
  4. package/dist/commands/cloud.js +7 -25
  5. package/dist/commands/computer.d.ts +1 -0
  6. package/dist/commands/computer.js +129 -8
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +66 -8
  10. package/dist/commands/factory.js +9 -4
  11. package/dist/commands/feed.d.ts +9 -0
  12. package/dist/commands/feed.js +69 -0
  13. package/dist/commands/inspect.js +1 -11
  14. package/dist/commands/logs.d.ts +5 -1
  15. package/dist/commands/logs.js +248 -3
  16. package/dist/commands/mcp.js +9 -6
  17. package/dist/commands/message.js +1 -4
  18. package/dist/commands/profiles.js +1 -18
  19. package/dist/commands/repo.js +33 -14
  20. package/dist/commands/resource-view.d.ts +1 -0
  21. package/dist/commands/resource-view.js +5 -17
  22. package/dist/commands/secrets.d.ts +23 -0
  23. package/dist/commands/secrets.js +174 -70
  24. package/dist/commands/sessions-picker.js +1 -18
  25. package/dist/commands/sessions.js +6 -8
  26. package/dist/commands/teams-picker.js +1 -32
  27. package/dist/commands/teams.js +5 -27
  28. package/dist/commands/tmux.js +1 -3
  29. package/dist/commands/view.js +1 -9
  30. package/dist/commands/worktree.js +1 -4
  31. package/dist/index.js +6 -2
  32. package/dist/lib/agents.d.ts +0 -4
  33. package/dist/lib/agents.js +20 -33
  34. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  35. package/dist/lib/auto-dispatch-linear.js +107 -0
  36. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  37. package/dist/lib/auto-dispatch-provider.js +25 -0
  38. package/dist/lib/auto-dispatch.d.ts +87 -0
  39. package/dist/lib/auto-dispatch.js +142 -0
  40. package/dist/lib/browser/cdp.js +11 -2
  41. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  42. package/dist/lib/browser/drivers/ssh.js +57 -18
  43. package/dist/lib/browser/login-detection.d.ts +94 -0
  44. package/dist/lib/browser/login-detection.js +274 -0
  45. package/dist/lib/browser/profiles.d.ts +17 -8
  46. package/dist/lib/browser/profiles.js +27 -8
  47. package/dist/lib/browser/refs.js +1 -5
  48. package/dist/lib/browser/secret-ref.d.ts +10 -0
  49. package/dist/lib/browser/secret-ref.js +14 -0
  50. package/dist/lib/browser/service.js +14 -12
  51. package/dist/lib/cli-resources.d.ts +0 -2
  52. package/dist/lib/cli-resources.js +30 -13
  53. package/dist/lib/cloud/rush.d.ts +15 -24
  54. package/dist/lib/cloud/rush.js +7 -32
  55. package/dist/lib/crabbox/cli.js +4 -1
  56. package/dist/lib/crabbox/lease.d.ts +6 -0
  57. package/dist/lib/crabbox/lease.js +40 -10
  58. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  59. package/dist/lib/crabbox/runtimes.js +98 -5
  60. package/dist/lib/daemon.d.ts +12 -9
  61. package/dist/lib/daemon.js +73 -17
  62. package/dist/lib/events.d.ts +31 -5
  63. package/dist/lib/events.js +288 -101
  64. package/dist/lib/exec.js +32 -14
  65. package/dist/lib/feed.d.ts +56 -0
  66. package/dist/lib/feed.js +251 -0
  67. package/dist/lib/format.d.ts +38 -0
  68. package/dist/lib/format.js +108 -0
  69. package/dist/lib/git.d.ts +21 -0
  70. package/dist/lib/git.js +92 -0
  71. package/dist/lib/hooks/cache.d.ts +9 -2
  72. package/dist/lib/hooks/cache.js +220 -8
  73. package/dist/lib/hooks.js +24 -10
  74. package/dist/lib/hosts/passthrough.js +1 -0
  75. package/dist/lib/platform/exec.d.ts +4 -1
  76. package/dist/lib/platform/exec.js +8 -2
  77. package/dist/lib/resources.d.ts +0 -8
  78. package/dist/lib/resources.js +0 -10
  79. package/dist/lib/rotate.js +2 -0
  80. package/dist/lib/runner.js +10 -2
  81. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  83. package/dist/lib/secrets/agent.d.ts +21 -0
  84. package/dist/lib/secrets/agent.js +63 -1
  85. package/dist/lib/secrets/bundles.d.ts +33 -1
  86. package/dist/lib/secrets/bundles.js +38 -8
  87. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  88. package/dist/lib/secrets/icloud-import.js +173 -0
  89. package/dist/lib/secrets/index.d.ts +36 -0
  90. package/dist/lib/secrets/index.js +99 -9
  91. package/dist/lib/secrets/remote.js +1 -1
  92. package/dist/lib/secrets/sync.js +1 -1
  93. package/dist/lib/session/active.d.ts +11 -1
  94. package/dist/lib/session/active.js +3 -0
  95. package/dist/lib/session/db.d.ts +1 -4
  96. package/dist/lib/session/db.js +20 -25
  97. package/dist/lib/session/discover.d.ts +2 -2
  98. package/dist/lib/session/discover.js +60 -48
  99. package/dist/lib/session/parse.js +35 -34
  100. package/dist/lib/session/render.d.ts +7 -3
  101. package/dist/lib/session/render.js +15 -9
  102. package/dist/lib/session/state.d.ts +55 -0
  103. package/dist/lib/session/state.js +100 -11
  104. package/dist/lib/session/types.d.ts +9 -0
  105. package/dist/lib/shims.d.ts +9 -3
  106. package/dist/lib/shims.js +44 -8
  107. package/dist/lib/ssh-tunnel.d.ts +33 -2
  108. package/dist/lib/ssh-tunnel.js +94 -7
  109. package/dist/lib/staleness/types.d.ts +0 -1
  110. package/dist/lib/startup/command-registry.d.ts +1 -0
  111. package/dist/lib/startup/command-registry.js +2 -0
  112. package/dist/lib/state.d.ts +2 -0
  113. package/dist/lib/state.js +25 -8
  114. package/dist/lib/teams/agents.js +6 -3
  115. package/dist/lib/types.d.ts +24 -1
  116. package/dist/lib/versions.d.ts +0 -26
  117. package/dist/lib/versions.js +2 -145
  118. package/dist/lib/warn-unpushed.d.ts +40 -0
  119. package/dist/lib/warn-unpushed.js +128 -0
  120. package/dist/lib/whats-new.d.ts +5 -3
  121. package/dist/lib/whats-new.js +25 -5
  122. package/package.json +3 -1
  123. package/dist/lib/resources/index.d.ts +0 -53
  124. package/dist/lib/resources/index.js +0 -76
@@ -25,26 +25,44 @@ export declare function connectSSH(endpoint: string, profile: BrowserProfile): P
25
25
  */
26
26
  export declare function encodePowerShell(script: string): string;
27
27
  /**
28
- * The PowerShell that launches the browser on a Windows remote. Two hard
28
+ * The PowerShell that launches the browser on a Windows remote. Three hard
29
29
  * requirements shaped this:
30
30
  * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
31
31
  * the session's job tree on disconnect, which reaps both `start /B` and
32
- * `Start-Process` children (verified against a real box). WMI
33
- * `Win32_Process.Create` spawns under the WMI provider service instead,
34
- * so the process survives after we drop the ssh connection and reconnect
35
- * over the CDP tunnel.
36
- * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
32
+ * `Start-Process` children (verified against a real box). A scheduled
33
+ * task runs under the Task Scheduler service, so the process survives
34
+ * after we drop the ssh connection and reconnect over the CDP tunnel.
35
+ * 2. The browser must run in the user's INTERACTIVE session. The previous
36
+ * WMI `Win32_Process.Create` launch also survived disconnect, but it
37
+ * spawned Edge in session 0 (the services session): the CDP socket bound
38
+ * and accepted, yet the DevTools server never initialized — every
39
+ * /json/version probe hung forever and DevToolsActivePort was never
40
+ * written (verified against a real box, Edg/150). A scheduled task
41
+ * registered and started by the logged-on user runs in that user's
42
+ * interactive session, where DevTools comes up normally.
43
+ * 3. A distinct `--user-data-dir` so a fresh instance bound to the debugging
37
44
  * port comes up even when the user already has Edge open.
38
45
  * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
39
- * registry at runtime rather than relying on a bare `msedge` name.
46
+ * registry at runtime rather than relying on a bare `msedge` name. The task is
47
+ * unregistered immediately after start — the browser keeps running; nothing
48
+ * lingers in the scheduler.
40
49
  */
41
50
  export declare function buildWindowsLaunchScript(browserType: string, port: number, customBinary?: string): string;
42
- /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
51
+ /**
52
+ * The PowerShell that kills whatever holds the CDP port on a Windows remote.
53
+ * Tree-kill (`taskkill /T`), not `Stop-Process`: Chromium is multi-process, and
54
+ * killing only the port-owning main process orphans its children. The orphans
55
+ * keep the profile's SingletonLock, so every later launch against the same
56
+ * `--user-data-dir` delegates to the zombie tree, exits, and never binds the
57
+ * CDP port — `browser start --host` then fails until someone hand-cleans the
58
+ * box (found live on win-mini by the #561 e2e suite).
59
+ */
43
60
  export declare function buildWindowsKillScript(port: number): string;
44
61
  /**
45
62
  * Build the remote command that launches the browser detached with a CDP port.
46
63
  * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
47
- * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
64
+ * starts it via an interactive one-shot scheduled task (encoded PowerShell)
65
+ * so it survives the ssh session AND serves CDP (session 0 never does).
48
66
  */
49
67
  export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string, port: number, customBinary?: string): string;
50
68
  /**
@@ -57,7 +75,7 @@ export declare function ensureRemoteBrowser(user: string, host: string, browserT
57
75
  export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
58
76
  /**
59
77
  * Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
60
- * WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
78
+ * task-launched (Windows) / detached (posix) browser process is not orphaned when
61
79
  * the ssh tunnel is torn down — killing only the tunnel left it running on
62
80
  * win-mini after every `browser stop`. Best-effort: never rejects.
63
81
  */
@@ -5,8 +5,11 @@ import { getPortOccupant } from '../chrome.js';
5
5
  import { parseEndpointUrl } from '../profiles.js';
6
6
  import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
7
7
  // shellQuote lives in the shared ssh-exec helper (single choke point); re-export
8
- // so existing importers of `shellQuote` from this module keep working.
9
- import { shellQuote } from '../../ssh-exec.js';
8
+ // so existing importers of `shellQuote` from this module keep working. SSH_OPTS
9
+ // is the shared hardened baseline (BatchMode + ConnectTimeout + keepalive) —
10
+ // reuse it so the raw-ssh spawns below fail fast on an unreachable host instead
11
+ // of hanging on the default ~127s TCP timeout, rather than re-listing options.
12
+ import { shellQuote, SSH_OPTS } from '../../ssh-exec.js';
10
13
  export { shellQuote };
11
14
  // The `ssh -L` tunnel spawn is shared with `agents computer --host`; it lives in
12
15
  // the single ssh-tunnel helper. Calling it with no options preserves this
@@ -168,18 +171,27 @@ export function encodePowerShell(script) {
168
171
  return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(script)}`;
169
172
  }
170
173
  /**
171
- * The PowerShell that launches the browser on a Windows remote. Two hard
174
+ * The PowerShell that launches the browser on a Windows remote. Three hard
172
175
  * requirements shaped this:
173
176
  * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
174
177
  * the session's job tree on disconnect, which reaps both `start /B` and
175
- * `Start-Process` children (verified against a real box). WMI
176
- * `Win32_Process.Create` spawns under the WMI provider service instead,
177
- * so the process survives after we drop the ssh connection and reconnect
178
- * over the CDP tunnel.
179
- * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
178
+ * `Start-Process` children (verified against a real box). A scheduled
179
+ * task runs under the Task Scheduler service, so the process survives
180
+ * after we drop the ssh connection and reconnect over the CDP tunnel.
181
+ * 2. The browser must run in the user's INTERACTIVE session. The previous
182
+ * WMI `Win32_Process.Create` launch also survived disconnect, but it
183
+ * spawned Edge in session 0 (the services session): the CDP socket bound
184
+ * and accepted, yet the DevTools server never initialized — every
185
+ * /json/version probe hung forever and DevToolsActivePort was never
186
+ * written (verified against a real box, Edg/150). A scheduled task
187
+ * registered and started by the logged-on user runs in that user's
188
+ * interactive session, where DevTools comes up normally.
189
+ * 3. A distinct `--user-data-dir` so a fresh instance bound to the debugging
180
190
  * port comes up even when the user already has Edge open.
181
191
  * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
182
- * registry at runtime rather than relying on a bare `msedge` name.
192
+ * registry at runtime rather than relying on a bare `msedge` name. The task is
193
+ * unregistered immediately after start — the browser keeps running; nothing
194
+ * lingers in the scheduler.
183
195
  */
184
196
  export function buildWindowsLaunchScript(browserType, port, customBinary) {
185
197
  let exeStmts;
@@ -211,21 +223,42 @@ export function buildWindowsLaunchScript(browserType, port, customBinary) {
211
223
  // source — a test asserts CDP is never opened to `*`.
212
224
  return [
213
225
  ...exeStmts,
214
- `$cl = '"' + $exe + '" --remote-debugging-port=${port}` +
226
+ // First-run / default-browser modals block automation (same rationale as
227
+ // the local launcher in chrome.ts), and the crash-restore bubble is worse
228
+ // here: `browser stop` hard-kills the remote process, so the NEXT launch
229
+ // against the same --user-data-dir marks the profile crashed and session
230
+ // restore swaps the initial page target — closing its CDP websocket while
231
+ // a command is pending (reproduced live: Page.captureScreenshot rejected
232
+ // with "CDP connection closed" on every reused-profile launch).
233
+ `$args = '--remote-debugging-port=${port}` +
215
234
  ` --remote-allow-origins=http://127.0.0.1:${port}` +
235
+ ` --no-first-run --no-default-browser-check` +
236
+ ` --hide-crash-restore-bubble --disable-session-crashed-bubble` +
216
237
  ` --disable-background-timer-throttling --user-data-dir="' + $env:TEMP + '\\agents-browser-${port}"'`,
217
- `Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $cl } | Out-Null`,
238
+ `$action = New-ScheduledTaskAction -Execute $exe -Argument $args`,
239
+ `Register-ScheduledTask -TaskName 'agents-browser-${port}' -Action $action -Force | Out-Null`,
240
+ `Start-ScheduledTask -TaskName 'agents-browser-${port}'`,
241
+ `Unregister-ScheduledTask -TaskName 'agents-browser-${port}' -Confirm:$false`,
218
242
  ].join('; ');
219
243
  }
220
- /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
244
+ /**
245
+ * The PowerShell that kills whatever holds the CDP port on a Windows remote.
246
+ * Tree-kill (`taskkill /T`), not `Stop-Process`: Chromium is multi-process, and
247
+ * killing only the port-owning main process orphans its children. The orphans
248
+ * keep the profile's SingletonLock, so every later launch against the same
249
+ * `--user-data-dir` delegates to the zombie tree, exits, and never binds the
250
+ * CDP port — `browser start --host` then fails until someone hand-cleans the
251
+ * box (found live on win-mini by the #561 e2e suite).
252
+ */
221
253
  export function buildWindowsKillScript(port) {
222
254
  return (`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue ` +
223
- `| ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`);
255
+ `| ForEach-Object { taskkill /PID $_.OwningProcess /T /F 2>$null } | Out-Null`);
224
256
  }
225
257
  /**
226
258
  * Build the remote command that launches the browser detached with a CDP port.
227
259
  * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
228
- * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
260
+ * starts it via an interactive one-shot scheduled task (encoded PowerShell)
261
+ * so it survives the ssh session AND serves CDP (session 0 never does).
229
262
  */
230
263
  export function buildLaunchCmd(remoteOs, browserType, port, customBinary) {
231
264
  if (remoteOs === 'windows') {
@@ -266,10 +299,12 @@ export async function ensureRemoteBrowser(user, host, browserType, port, remoteO
266
299
  // ignored and this resolved on close regardless of exit code, so failures
267
300
  // were swallowed and only re-emerged 8s later as a generic
268
301
  // "SSH tunnel failed to establish".
302
+ // Options BEFORE the target (matching `sshExec`): OpenSSH's BSD getopt on
303
+ // macOS stops at the first non-option, so any `-o` after `user@host` would
304
+ // be swallowed into the remote command instead of applied.
269
305
  const child = spawn('ssh', [
306
+ ...SSH_OPTS,
270
307
  `${user}@${host}`,
271
- '-o',
272
- 'BatchMode=yes',
273
308
  remoteCmd,
274
309
  ], { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
275
310
  let stderr = '';
@@ -319,7 +354,7 @@ export async function restartRemoteBrowser(user, host, browserType, port, remote
319
354
  }
320
355
  /**
321
356
  * Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
322
- * WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
357
+ * task-launched (Windows) / detached (posix) browser process is not orphaned when
323
358
  * the ssh tunnel is torn down — killing only the tunnel left it running on
324
359
  * win-mini after every `browser stop`. Best-effort: never rejects.
325
360
  */
@@ -328,7 +363,11 @@ export function killRemoteBrowser(user, host, remoteOs, port) {
328
363
  }
329
364
  function runSSHCommand(user, host, cmd) {
330
365
  return new Promise((resolve) => {
331
- const child = spawn('ssh', [`${user}@${host}`, '-o', 'BatchMode=yes', cmd], {
366
+ // Reuse the shared hardened baseline so a hung TCP SYN to an unreachable
367
+ // host is bounded by ConnectTimeout (the local 3s kill below only bounds a
368
+ // connected-but-slow command, not the connect itself). Options precede the
369
+ // target — see ensureRemoteBrowser.
370
+ const child = spawn('ssh', [...SSH_OPTS, `${user}@${host}`, cmd], {
332
371
  stdio: 'ignore',
333
372
  windowsHide: true,
334
373
  });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Best-effort login-state detection for browser profiles.
3
+ *
4
+ * Reads a profile's Chromium cookie store (presence only — never decrypts the
5
+ * Keychain-encrypted values) to tell which login-gated services have a live
6
+ * session in that profile. Powers the `agents browser start` guardrail (warn
7
+ * when an agent opens a logged-out profile for a login-gated URL) and the
8
+ * `agents browser profiles logins` view.
9
+ *
10
+ * Everything here is advisory: any failure (missing DB, locked file, unknown
11
+ * schema) degrades to "no known session" and NEVER throws to callers — browser
12
+ * start must not slow down or break because cookie inspection hiccuped.
13
+ */
14
+ /** A login-gated service signature: host substrings + the auth-cookie names
15
+ * whose presence indicates a live session, plus optional login metadata. */
16
+ interface ServiceSignature {
17
+ hosts: string[];
18
+ cookies: string[];
19
+ /**
20
+ * Where an interactive login for this service starts, and the bundle key
21
+ * PREFIX its credentials live under. By convention a profile's `--secrets`
22
+ * bundle holds `<PREFIX>_USERNAME` / `<PREFIX>_PASSWORD` (see
23
+ * `credKeysForService`). The prefix is explicit — never string-munged from the
24
+ * service id — so e.g. `x` → `X`, `google` → `GOOGLE`.
25
+ */
26
+ login?: {
27
+ loginUrl: string;
28
+ keyPrefix: string;
29
+ };
30
+ }
31
+ /**
32
+ * Known login-gated services. Presence of ANY listed cookie on a matching host
33
+ * (unexpired, or a session cookie) = logged in. Deliberately conservative: a
34
+ * service absent from this map simply never triggers a warning — we do not
35
+ * guess. Each cookie name here is the definitive authenticated-session token for
36
+ * that service (e.g. LinkedIn's `li_at` is only set for a signed-in member;
37
+ * visitor cookies like `bcookie`/`JSESSIONID` are NOT auth). Services whose
38
+ * session is not reliably expressed as a recognizable cookie (e.g. Attio, whose
39
+ * only same-site cookies are third-party analytics) are deliberately omitted so
40
+ * we never emit a false "logged out" warning. Extend as needed.
41
+ */
42
+ export declare const AUTH_SIGNATURES: Record<string, ServiceSignature>;
43
+ /**
44
+ * The bundle keys a profile's `--secrets` bundle should hold to log into
45
+ * `service`: `<PREFIX>_USERNAME` / `<PREFIX>_PASSWORD`. Null for services with no
46
+ * login metadata. Feed a resolved value to the page with
47
+ * `agents browser type <ref> --secret <bundle>/<KEY>` (never printed).
48
+ */
49
+ export declare function credKeysForService(service: string): {
50
+ user: string;
51
+ pass: string;
52
+ } | null;
53
+ /** The interactive login URL for a service, or null. */
54
+ export declare function loginUrlForService(service: string): string | null;
55
+ export interface CookieRow {
56
+ host_key: string;
57
+ name: string;
58
+ }
59
+ /**
60
+ * Pure core: given a profile's (already expiry-filtered) cookie rows, return the
61
+ * services with a live session. Exported for direct unit testing without disk.
62
+ */
63
+ export declare function detectServices(rows: CookieRow[]): string[];
64
+ /** Map a URL to a known login-gated service key, or null. */
65
+ export declare function serviceForUrl(url: string): string | null;
66
+ /** A saved-login row from Chromium `Login Data` (username plaintext; password
67
+ * encrypted and never read). */
68
+ export interface LoginRow {
69
+ origin_url: string;
70
+ username_value: string;
71
+ signon_realm: string;
72
+ }
73
+ /**
74
+ * Map a profile's saved logins to service → account username (identity, not
75
+ * session). Best-effort; `{}` if `Login Data` is unreadable. The first non-empty
76
+ * username per service wins.
77
+ */
78
+ export declare function accountsForProfile(profileName: string): Promise<Record<string, string>>;
79
+ /**
80
+ * For each service with a LIVE session in the profile (cookie-gated), the
81
+ * account it is signed in as (from saved logins). Intersecting with live
82
+ * sessions means a stale saved-login for a service you've since logged out of
83
+ * never shows.
84
+ */
85
+ export declare function loginsWithAccountsForProfile(profileName: string): Promise<Array<{
86
+ service: string;
87
+ username?: string;
88
+ }>>;
89
+ /** Services with a live session in the given profile (best-effort; [] if none
90
+ * detected or the cookie store is unreadable). */
91
+ export declare function loginsForProfile(profileName: string): Promise<string[]>;
92
+ /** Profile names that have a live session for the given service. */
93
+ export declare function profilesLoggedInto(service: string): Promise<string[]>;
94
+ export {};
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Best-effort login-state detection for browser profiles.
3
+ *
4
+ * Reads a profile's Chromium cookie store (presence only — never decrypts the
5
+ * Keychain-encrypted values) to tell which login-gated services have a live
6
+ * session in that profile. Powers the `agents browser start` guardrail (warn
7
+ * when an agent opens a logged-out profile for a login-gated URL) and the
8
+ * `agents browser profiles logins` view.
9
+ *
10
+ * Everything here is advisory: any failure (missing DB, locked file, unknown
11
+ * schema) degrades to "no known session" and NEVER throws to callers — browser
12
+ * start must not slow down or break because cookie inspection hiccuped.
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as os from 'os';
16
+ import * as path from 'path';
17
+ import Database from '../sqlite.js';
18
+ import { getBrowserRuntimeDir, listProfiles } from './profiles.js';
19
+ /**
20
+ * Known login-gated services. Presence of ANY listed cookie on a matching host
21
+ * (unexpired, or a session cookie) = logged in. Deliberately conservative: a
22
+ * service absent from this map simply never triggers a warning — we do not
23
+ * guess. Each cookie name here is the definitive authenticated-session token for
24
+ * that service (e.g. LinkedIn's `li_at` is only set for a signed-in member;
25
+ * visitor cookies like `bcookie`/`JSESSIONID` are NOT auth). Services whose
26
+ * session is not reliably expressed as a recognizable cookie (e.g. Attio, whose
27
+ * only same-site cookies are third-party analytics) are deliberately omitted so
28
+ * we never emit a false "logged out" warning. Extend as needed.
29
+ */
30
+ export const AUTH_SIGNATURES = {
31
+ linkedin: {
32
+ hosts: ['linkedin.com'],
33
+ cookies: ['li_at'],
34
+ login: { loginUrl: 'https://www.linkedin.com/login', keyPrefix: 'LINKEDIN' },
35
+ },
36
+ google: {
37
+ hosts: ['google.com'],
38
+ cookies: ['SID', 'SAPISID', '__Secure-1PSID', '__Secure-3PSID'],
39
+ login: { loginUrl: 'https://accounts.google.com/', keyPrefix: 'GOOGLE' },
40
+ },
41
+ x: {
42
+ hosts: ['x.com', 'twitter.com'],
43
+ cookies: ['auth_token'],
44
+ login: { loginUrl: 'https://x.com/login', keyPrefix: 'X' },
45
+ },
46
+ reddit: {
47
+ hosts: ['reddit.com'],
48
+ cookies: ['reddit_session', 'token_v2'],
49
+ login: { loginUrl: 'https://www.reddit.com/login', keyPrefix: 'REDDIT' },
50
+ },
51
+ github: {
52
+ hosts: ['github.com'],
53
+ cookies: ['user_session'],
54
+ login: { loginUrl: 'https://github.com/login', keyPrefix: 'GITHUB' },
55
+ },
56
+ };
57
+ /**
58
+ * The bundle keys a profile's `--secrets` bundle should hold to log into
59
+ * `service`: `<PREFIX>_USERNAME` / `<PREFIX>_PASSWORD`. Null for services with no
60
+ * login metadata. Feed a resolved value to the page with
61
+ * `agents browser type <ref> --secret <bundle>/<KEY>` (never printed).
62
+ */
63
+ export function credKeysForService(service) {
64
+ const login = AUTH_SIGNATURES[service]?.login;
65
+ if (!login)
66
+ return null;
67
+ return { user: `${login.keyPrefix}_USERNAME`, pass: `${login.keyPrefix}_PASSWORD` };
68
+ }
69
+ /** The interactive login URL for a service, or null. */
70
+ export function loginUrlForService(service) {
71
+ return AUTH_SIGNATURES[service]?.login?.loginUrl ?? null;
72
+ }
73
+ /**
74
+ * Current time as microseconds since 1601-01-01 (Chromium's `expires_utc`
75
+ * epoch), as a BigInt. Chromium expiries routinely exceed 2^53, so this is
76
+ * bound into SQL as a BigInt and the comparison happens IN SQLite — the huge
77
+ * integer never marshals back to JS (node:sqlite throws RangeError if it does).
78
+ * 11644473600000 = ms between 1601-01-01 and 1970-01-01.
79
+ */
80
+ function chromeNowMicrosBigInt() {
81
+ return (BigInt(Date.now()) + 11644473600000n) * 1000n;
82
+ }
83
+ function hostMatches(hostKey, host) {
84
+ return hostKey === host || hostKey === '.' + host || hostKey.endsWith('.' + host);
85
+ }
86
+ /**
87
+ * Pure core: given a profile's (already expiry-filtered) cookie rows, return the
88
+ * services with a live session. Exported for direct unit testing without disk.
89
+ */
90
+ export function detectServices(rows) {
91
+ const services = [];
92
+ for (const [service, sig] of Object.entries(AUTH_SIGNATURES)) {
93
+ const found = rows.some((r) => sig.cookies.includes(r.name) && sig.hosts.some((h) => hostMatches(r.host_key, h)));
94
+ if (found)
95
+ services.push(service);
96
+ }
97
+ return services;
98
+ }
99
+ /** Map a URL to a known login-gated service key, or null. */
100
+ export function serviceForUrl(url) {
101
+ let host;
102
+ try {
103
+ host = new URL(url).hostname;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ for (const [service, sig] of Object.entries(AUTH_SIGNATURES)) {
109
+ if (sig.hosts.some((h) => host === h || host.endsWith('.' + h)))
110
+ return service;
111
+ }
112
+ return null;
113
+ }
114
+ /**
115
+ * Locate candidate Chromium DBs for a profile at the given relative paths. The
116
+ * live runtime dir is keyed by the composite `<profile>@<endpoint>` name, so we
117
+ * scan the browser cache root for `<profile>` and `<profile>@*` dirs and return
118
+ * every matching DB found, most-recently-modified first.
119
+ */
120
+ function profileDbCandidates(profileName, relPaths) {
121
+ const root = getBrowserRuntimeDir();
122
+ let entries;
123
+ try {
124
+ entries = fs.readdirSync(root);
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ const dirs = entries.filter((e) => e === profileName || e.startsWith(profileName + '@'));
130
+ const found = [];
131
+ for (const d of dirs) {
132
+ for (const rel of relPaths) {
133
+ const p = path.join(root, d, ...rel);
134
+ try {
135
+ found.push({ p, mtime: fs.statSync(p).mtimeMs });
136
+ }
137
+ catch {
138
+ /* not present */
139
+ }
140
+ }
141
+ }
142
+ return found.sort((a, b) => b.mtime - a.mtime).map((f) => f.p);
143
+ }
144
+ function cookieDbCandidates(profileName) {
145
+ return profileDbCandidates(profileName, [
146
+ ['chrome-data', 'Default', 'Cookies'],
147
+ ['chrome-data', 'Default', 'Network', 'Cookies'],
148
+ ]);
149
+ }
150
+ function loginDbCandidates(profileName) {
151
+ return profileDbCandidates(profileName, [['chrome-data', 'Default', 'Login Data']]);
152
+ }
153
+ /**
154
+ * Open a Chromium SQLite store safely: copy the DB (and any WAL/SHM sidecars) to
155
+ * a temp dir first so a running browser's lock never blocks us and we never
156
+ * touch the live file, run `fn`, then clean up. Returns `fallback` on any error.
157
+ */
158
+ function withCopiedDb(dbPath, fn, fallback) {
159
+ let tmpDir = null;
160
+ try {
161
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agents-browserdb-'));
162
+ const tmpDb = path.join(tmpDir, 'db');
163
+ fs.copyFileSync(dbPath, tmpDb);
164
+ for (const suffix of ['-wal', '-shm']) {
165
+ if (fs.existsSync(dbPath + suffix))
166
+ fs.copyFileSync(dbPath + suffix, tmpDb + suffix);
167
+ }
168
+ const db = new Database(tmpDb);
169
+ try {
170
+ return fn(db);
171
+ }
172
+ finally {
173
+ db.close();
174
+ }
175
+ }
176
+ catch {
177
+ return fallback;
178
+ }
179
+ finally {
180
+ if (tmpDir) {
181
+ try {
182
+ fs.rmSync(tmpDir, { recursive: true, force: true });
183
+ }
184
+ catch {
185
+ /* temp cleanup best-effort */
186
+ }
187
+ }
188
+ }
189
+ }
190
+ /** Read (expiry-filtered) cookie rows from a Chromium cookie DB. [] on failure. */
191
+ function readCookieRows(dbPath) {
192
+ return withCopiedDb(dbPath, (db) => {
193
+ // Filter expiry in SQL (session cookies have expires_utc = 0; otherwise it
194
+ // must be in the future) and select only text columns, so the huge
195
+ // microsecond integer never crosses into JS.
196
+ const rows = db
197
+ .prepare('SELECT host_key, name FROM cookies WHERE expires_utc = 0 OR expires_utc > ?')
198
+ .all(chromeNowMicrosBigInt());
199
+ return rows.map((r) => ({
200
+ host_key: String(r.host_key ?? ''),
201
+ name: String(r.name ?? ''),
202
+ }));
203
+ }, []);
204
+ }
205
+ /** Read saved-login rows from a Chromium `Login Data` DB. Skips
206
+ * user-blacklisted origins ("never save"). Selects only text columns —
207
+ * `password_value` (encrypted) is never touched. [] on failure. */
208
+ function readLoginRows(dbPath) {
209
+ return withCopiedDb(dbPath, (db) => {
210
+ const rows = db
211
+ .prepare('SELECT origin_url, username_value, signon_realm FROM logins WHERE blacklisted_by_user = 0')
212
+ .all();
213
+ return rows.map((r) => ({
214
+ origin_url: String(r.origin_url ?? ''),
215
+ username_value: String(r.username_value ?? ''),
216
+ signon_realm: String(r.signon_realm ?? ''),
217
+ }));
218
+ }, []);
219
+ }
220
+ /**
221
+ * Map a profile's saved logins to service → account username (identity, not
222
+ * session). Best-effort; `{}` if `Login Data` is unreadable. The first non-empty
223
+ * username per service wins.
224
+ */
225
+ export async function accountsForProfile(profileName) {
226
+ const candidates = loginDbCandidates(profileName);
227
+ if (candidates.length === 0)
228
+ return {};
229
+ const rows = readLoginRows(candidates[0]);
230
+ const out = {};
231
+ for (const r of rows) {
232
+ if (!r.username_value)
233
+ continue;
234
+ const service = serviceForUrl(r.origin_url) ?? serviceForUrl(r.signon_realm);
235
+ if (service && !out[service])
236
+ out[service] = r.username_value;
237
+ }
238
+ return out;
239
+ }
240
+ /**
241
+ * For each service with a LIVE session in the profile (cookie-gated), the
242
+ * account it is signed in as (from saved logins). Intersecting with live
243
+ * sessions means a stale saved-login for a service you've since logged out of
244
+ * never shows.
245
+ */
246
+ export async function loginsWithAccountsForProfile(profileName) {
247
+ const active = await loginsForProfile(profileName);
248
+ if (active.length === 0)
249
+ return [];
250
+ const accounts = await accountsForProfile(profileName);
251
+ return active.map((service) => ({ service, username: accounts[service] }));
252
+ }
253
+ /** Services with a live session in the given profile (best-effort; [] if none
254
+ * detected or the cookie store is unreadable). */
255
+ export async function loginsForProfile(profileName) {
256
+ const candidates = cookieDbCandidates(profileName);
257
+ if (candidates.length === 0)
258
+ return [];
259
+ const rows = readCookieRows(candidates[0]);
260
+ if (rows.length === 0)
261
+ return [];
262
+ return detectServices(rows);
263
+ }
264
+ /** Profile names that have a live session for the given service. */
265
+ export async function profilesLoggedInto(service) {
266
+ const profiles = await listProfiles();
267
+ const out = [];
268
+ for (const p of profiles) {
269
+ const logins = await loginsForProfile(p.name);
270
+ if (logins.includes(service))
271
+ out.push(p.name);
272
+ }
273
+ return out;
274
+ }
@@ -1,20 +1,29 @@
1
1
  import type { BrowserProfile } from './types.js';
2
2
  export type { BrowserProfile } from './types.js';
3
3
  export declare const DEFAULT_BROWSER_PROFILE_NAME = "default";
4
+ /**
5
+ * The device-local configured default profile name (set via
6
+ * `agents browser profiles set-default`), or undefined when unset. When set, it
7
+ * is the profile `agents browser start` resolves to for BOTH the no-`--profile`
8
+ * path and an explicit `--profile default`. Stored per-machine — see
9
+ * `Meta.defaultBrowserProfile`.
10
+ */
11
+ export declare function getConfiguredDefaultProfileName(): string | undefined;
4
12
  export declare function getBrowserRuntimeDir(): string;
5
13
  export declare function getProfileRuntimeDir(name: string): string;
6
14
  export declare function listProfiles(): Promise<BrowserProfile[]>;
7
15
  export declare function getProfile(name: string): Promise<BrowserProfile | null>;
8
16
  /**
9
- * Ensure a `default` profile exists, auto-picking the first installed
10
- * Chromium-family browser per the platform priority list in chrome.ts.
17
+ * Resolve the profile `agents browser start` uses when no `--profile` is given.
11
18
  *
12
- * Re-uses an existing `default` profile as-is (we don't second-guess the user
13
- * if they've already customized it). On first run we walk the priority list
14
- * (macOS: chrome > brave > edge > chromium > comet; Linux: chrome > chromium >
15
- * brave > edge; Windows: edge > chrome > brave > comet) and pin the profile to the
16
- * first match. Throws an actionable error if none of those binaries are
17
- * installed so the user knows exactly which browsers we'd accept.
19
+ * Order: (1) the device-local configured default (`agents browser profiles
20
+ * set-default <name>`) when it names an existing profile; (2) an existing
21
+ * `default` profile as-is; (3) auto-pick the first installed Chromium-family
22
+ * browser per the platform priority list in chrome.ts (macOS: chrome > brave >
23
+ * edge > chromium > comet; Linux: chrome > chromium > brave > edge; Windows:
24
+ * edge > chrome > brave > comet) and pin a new `default` profile to it. Throws an
25
+ * actionable error if none of those binaries are installed. A configured default
26
+ * that no longer exists warns and falls through to (2)/(3) — never a hard fail.
18
27
  */
19
28
  export declare function ensureDefaultBrowserProfile(): Promise<BrowserProfile>;
20
29
  /**
@@ -3,6 +3,16 @@ import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, readMeta, writeMeta,
3
3
  import { findBrowserPath, findFirstInstalledBrowser, isPortInUse } from './chrome.js';
4
4
  import { DEFAULT_VIEWPORT } from './devices.js';
5
5
  export const DEFAULT_BROWSER_PROFILE_NAME = 'default';
6
+ /**
7
+ * The device-local configured default profile name (set via
8
+ * `agents browser profiles set-default`), or undefined when unset. When set, it
9
+ * is the profile `agents browser start` resolves to for BOTH the no-`--profile`
10
+ * path and an explicit `--profile default`. Stored per-machine — see
11
+ * `Meta.defaultBrowserProfile`.
12
+ */
13
+ export function getConfiguredDefaultProfileName() {
14
+ return readMeta().defaultBrowserProfile || undefined;
15
+ }
6
16
  export function getBrowserRuntimeDir() {
7
17
  return getBrowserRuntimeDirRoot();
8
18
  }
@@ -69,17 +79,26 @@ export async function getProfile(name) {
69
79
  return configToProfile(name, config);
70
80
  }
71
81
  /**
72
- * Ensure a `default` profile exists, auto-picking the first installed
73
- * Chromium-family browser per the platform priority list in chrome.ts.
82
+ * Resolve the profile `agents browser start` uses when no `--profile` is given.
74
83
  *
75
- * Re-uses an existing `default` profile as-is (we don't second-guess the user
76
- * if they've already customized it). On first run we walk the priority list
77
- * (macOS: chrome > brave > edge > chromium > comet; Linux: chrome > chromium >
78
- * brave > edge; Windows: edge > chrome > brave > comet) and pin the profile to the
79
- * first match. Throws an actionable error if none of those binaries are
80
- * installed so the user knows exactly which browsers we'd accept.
84
+ * Order: (1) the device-local configured default (`agents browser profiles
85
+ * set-default <name>`) when it names an existing profile; (2) an existing
86
+ * `default` profile as-is; (3) auto-pick the first installed Chromium-family
87
+ * browser per the platform priority list in chrome.ts (macOS: chrome > brave >
88
+ * edge > chromium > comet; Linux: chrome > chromium > brave > edge; Windows:
89
+ * edge > chrome > brave > comet) and pin a new `default` profile to it. Throws an
90
+ * actionable error if none of those binaries are installed. A configured default
91
+ * that no longer exists warns and falls through to (2)/(3) — never a hard fail.
81
92
  */
82
93
  export async function ensureDefaultBrowserProfile() {
94
+ const configured = getConfiguredDefaultProfileName();
95
+ if (configured) {
96
+ const chosen = await getProfile(configured);
97
+ if (chosen)
98
+ return chosen;
99
+ console.warn(`warning: configured default browser profile "${configured}" no longer exists; ` +
100
+ `falling back to auto-detect. Fix with: agents browser profiles set-default <name> (or --unset)`);
101
+ }
83
102
  const existing = await getProfile(DEFAULT_BROWSER_PROFILE_NAME);
84
103
  if (existing)
85
104
  return existing;