@phnx-labs/agents-cli 1.22.74 → 1.22.76

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 (148) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +21 -9
  3. package/dist/bootstrap.js +7 -7
  4. package/dist/cli/command-registry.js +5 -0
  5. package/dist/commands/artifacts-setup.js +1 -1
  6. package/dist/commands/artifacts.js +1 -1
  7. package/dist/commands/auth.js +7 -1
  8. package/dist/commands/browser.js +104 -10
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/computer.d.ts +1 -0
  11. package/dist/commands/computer.js +26 -7
  12. package/dist/commands/config.js +27 -4
  13. package/dist/commands/cost.js +6 -4
  14. package/dist/commands/doctor.d.ts +6 -5
  15. package/dist/commands/doctor.js +32 -274
  16. package/dist/commands/exec.d.ts +2 -0
  17. package/dist/commands/exec.js +9 -2
  18. package/dist/commands/harness.d.ts +1 -0
  19. package/dist/commands/harness.js +11 -3
  20. package/dist/commands/hooks.js +7 -6
  21. package/dist/commands/mcp.js +7 -6
  22. package/dist/commands/memory.js +7 -7
  23. package/dist/commands/monitors.js +3 -2
  24. package/dist/commands/open.d.ts +25 -12
  25. package/dist/commands/open.js +24 -10
  26. package/dist/commands/permissions.js +7 -6
  27. package/dist/commands/plugins.js +21 -17
  28. package/dist/commands/route.js +33 -16
  29. package/dist/commands/rules.js +7 -12
  30. package/dist/commands/sessions-share.js +1 -1
  31. package/dist/commands/setup-watchdog.js +2 -2
  32. package/dist/commands/setup.js +22 -1
  33. package/dist/commands/share.js +26 -10
  34. package/dist/commands/skills.js +7 -6
  35. package/dist/commands/subagents.js +7 -6
  36. package/dist/commands/sync.js +81 -10
  37. package/dist/commands/view.js +4 -1
  38. package/dist/commands/watchdog.d.ts +1 -1
  39. package/dist/commands/watchdog.js +10 -10
  40. package/dist/commands/webhook.d.ts +4 -0
  41. package/dist/commands/webhook.js +22 -4
  42. package/dist/commands/workflows.js +7 -6
  43. package/dist/lib/account-registry.js +27 -6
  44. package/dist/lib/accounting/rotate.d.ts +3 -1
  45. package/dist/lib/accounting/rotate.js +8 -4
  46. package/dist/lib/auth-health.d.ts +2 -0
  47. package/dist/lib/auth-health.js +2 -0
  48. package/dist/lib/browser/chrome.d.ts +21 -0
  49. package/dist/lib/browser/chrome.js +60 -3
  50. package/dist/lib/browser/drivers/local.d.ts +21 -0
  51. package/dist/lib/browser/drivers/local.js +102 -9
  52. package/dist/lib/browser/profiles.d.ts +29 -1
  53. package/dist/lib/browser/profiles.js +50 -1
  54. package/dist/lib/browser/types.d.ts +18 -0
  55. package/dist/lib/computer/computer-rpc.d.ts +6 -1
  56. package/dist/lib/computer/computer-rpc.js +23 -3
  57. package/dist/lib/computer/des.d.ts +1 -0
  58. package/dist/lib/computer/des.js +114 -0
  59. package/dist/lib/computer/rfb-client.d.ts +53 -0
  60. package/dist/lib/computer/rfb-client.js +562 -0
  61. package/dist/lib/config-keys.d.ts +7 -2
  62. package/dist/lib/config-keys.js +17 -2
  63. package/dist/lib/daemon/auth-sync-service.js +3 -0
  64. package/dist/lib/daemon/daemon.js +17 -10
  65. package/dist/lib/daemon/session-summarizer-service.d.ts +24 -0
  66. package/dist/lib/daemon/session-summarizer-service.js +39 -0
  67. package/dist/lib/daemon/usage-sync-service.js +3 -0
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/daemon-ticks.d.ts +2 -2
  71. package/dist/lib/daemon-ticks.js +2 -1
  72. package/dist/lib/daemon-webhooks.js +15 -2
  73. package/dist/lib/deeplink/register.js +10 -9
  74. package/dist/lib/deeplink/url.d.ts +4 -4
  75. package/dist/lib/deeplink/url.js +4 -4
  76. package/dist/lib/device-config.js +25 -0
  77. package/dist/lib/devices/doctor-findings.d.ts +4 -4
  78. package/dist/lib/devices/doctor-findings.js +14 -8
  79. package/dist/lib/devices/registry.js +2 -0
  80. package/dist/lib/devices/stats-cache.d.ts +4 -0
  81. package/dist/lib/devices/stats-cache.js +19 -0
  82. package/dist/lib/drift-sync.d.ts +3 -1
  83. package/dist/lib/drift-sync.js +16 -5
  84. package/dist/lib/exec.d.ts +2 -0
  85. package/dist/lib/exec.js +16 -1
  86. package/dist/lib/fleet-shared-repo-sync.d.ts +12 -0
  87. package/dist/lib/fleet-shared-repo-sync.js +101 -4
  88. package/dist/lib/fleet-shared-state.d.ts +8 -0
  89. package/dist/lib/heal.d.ts +4 -3
  90. package/dist/lib/heal.js +5 -4
  91. package/dist/lib/hosts/ready.d.ts +1 -1
  92. package/dist/lib/hosts/ready.js +16 -4
  93. package/dist/lib/hosts/reconnect.js +4 -2
  94. package/dist/lib/identity/client.d.ts +6 -0
  95. package/dist/lib/identity/index.d.ts +16 -0
  96. package/dist/lib/identity/index.js +25 -1
  97. package/dist/lib/profiles.d.ts +2 -0
  98. package/dist/lib/profiles.js +28 -9
  99. package/dist/lib/reconcile-and-repair.d.ts +109 -0
  100. package/dist/lib/reconcile-and-repair.js +267 -0
  101. package/dist/lib/routers.d.ts +12 -1
  102. package/dist/lib/routers.js +30 -1
  103. package/dist/lib/scheduling/routines.js +8 -2
  104. package/dist/lib/session/active.d.ts +13 -0
  105. package/dist/lib/session/db.d.ts +47 -7
  106. package/dist/lib/session/db.js +114 -12
  107. package/dist/lib/session/mirror.js +58 -0
  108. package/dist/lib/session/remote/remote-list.d.ts +2 -0
  109. package/dist/lib/session/remote/remote-list.js +4 -0
  110. package/dist/lib/session/remote/watch.js +22 -2
  111. package/dist/lib/session/session-cache.d.ts +19 -0
  112. package/dist/lib/session/session-cache.js +46 -0
  113. package/dist/lib/session/types.d.ts +34 -0
  114. package/dist/lib/share/backend.d.ts +6 -4
  115. package/dist/lib/share/backend.js +10 -8
  116. package/dist/lib/share/config.d.ts +4 -3
  117. package/dist/lib/share/config.js +10 -1
  118. package/dist/lib/share/delete.d.ts +1 -1
  119. package/dist/lib/share/delete.js +1 -1
  120. package/dist/lib/share/html.d.ts +1 -1
  121. package/dist/lib/share/html.js +1 -1
  122. package/dist/lib/share/provision.d.ts +1 -1
  123. package/dist/lib/share/provision.js +2 -2
  124. package/dist/lib/share/publish.d.ts +23 -7
  125. package/dist/lib/share/publish.js +58 -12
  126. package/dist/lib/share/worker-template.js +221 -60
  127. package/dist/lib/startup/command-registry.js +2 -2
  128. package/dist/lib/state.d.ts +15 -0
  129. package/dist/lib/state.js +29 -7
  130. package/dist/lib/summarizer/config.d.ts +46 -0
  131. package/dist/lib/summarizer/config.js +83 -0
  132. package/dist/lib/summarizer/pass.d.ts +45 -0
  133. package/dist/lib/summarizer/pass.js +112 -0
  134. package/dist/lib/summarizer/summarize.d.ts +68 -0
  135. package/dist/lib/summarizer/summarize.js +120 -0
  136. package/dist/lib/teams/agents.d.ts +4 -3
  137. package/dist/lib/teams/agents.js +12 -4
  138. package/dist/lib/teams/scheduler.d.ts +4 -2
  139. package/dist/lib/teams/scheduler.js +6 -6
  140. package/dist/lib/tmux/session.d.ts +2 -0
  141. package/dist/lib/tmux/session.js +7 -1
  142. package/dist/lib/types.d.ts +20 -0
  143. package/dist/lib/verbs.d.ts +23 -0
  144. package/dist/lib/verbs.js +24 -0
  145. package/dist/lib/view-types.d.ts +4 -0
  146. package/dist/lib/watchdog/rotate.d.ts +1 -1
  147. package/dist/lib/watchdog/rotate.js +1 -1
  148. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { withAliases } from '../lib/verbs.js';
1
2
  import chalk from 'chalk';
2
3
  import ora from 'ora';
3
4
  import * as fs from 'fs';
@@ -56,8 +57,8 @@ Examples:
56
57
  # Remove from version homes (and central storage on second run)
57
58
  agents workflows remove code-review
58
59
  `);
59
- workflowsCmd
60
- .command('list [agent]')
60
+ withAliases(workflowsCmd
61
+ .command('list [agent]'), 'list')
61
62
  .description('Show installed workflows and which agent versions they are synced to')
62
63
  .option('-a, --agent <agent>', 'Filter to a specific agent')
63
64
  .action(async (agentArg, options) => {
@@ -260,8 +261,8 @@ Examples:
260
261
  process.exit(1);
261
262
  }
262
263
  });
263
- workflowsCmd
264
- .command('remove [name]')
264
+ withAliases(workflowsCmd
265
+ .command('remove [name]'), 'remove')
265
266
  .description('Remove a workflow from version homes (interactive picker if no name given)')
266
267
  .addHelpText('after', `
267
268
  Examples:
@@ -365,8 +366,8 @@ Examples:
365
366
  console.log(chalk.gray('Central source unchanged. Use "agents workflows remove <name>" again to remove from ~/.agents/workflows/.'));
366
367
  }
367
368
  });
368
- workflowsCmd
369
- .command('view [name]')
369
+ withAliases(workflowsCmd
370
+ .command('view [name]'), 'view')
370
371
  .description('Read workflow details (description, subagents, model, MCP)')
371
372
  .addHelpText('after', `
372
373
  Examples:
@@ -187,11 +187,32 @@ function nativeRowsForNameOrId(meta, name) {
187
187
  return [];
188
188
  return nativeIdentityRows(meta, found.agent, found.identityKey);
189
189
  }
190
- function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
190
+ /**
191
+ * Native label names are unique per HARNESS, not globally (PHNX-3887).
192
+ *
193
+ * One human identity is commonly signed into several harnesses —
194
+ * `muqsitnawaz@icloud.com` is a claude login AND a codex login AND a grok login.
195
+ * A global namespace let whichever harness was labelled first squat the good
196
+ * name, forcing prefixed junk (`cxicloud`, `gkicloud`) on the rest. Nothing is
197
+ * actually ambiguous at the point of use: the selector is `<harness>#<label>`,
198
+ * and `findUnifiedAccount` already disambiguates via `preferAgent`.
199
+ *
200
+ * Pass `agent` to scope the check to that harness. Omit it for management
201
+ * lookups with no harness in hand (rename/remove), which keep the old
202
+ * fleet-wide check so a rename cannot collide with an unrelated harness's row.
203
+ *
204
+ * Provider (non-native) accounts stay globally unique — they are selected by
205
+ * bare name via `--account`, with no harness to scope them by.
206
+ */
207
+ function assertUniqueUnifiedName(name, meta, doc, exceptIds, agent) {
191
208
  const needle = name.toLowerCase();
192
- const nativeHits = listNativeAccounts(meta).filter(account => account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
193
- if (nativeHits.some(account => !exceptIds?.has(account.id)))
194
- throw new Error(`Account '${name}' already exists.`);
209
+ const nativeHits = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent)
210
+ && (account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle));
211
+ if (nativeHits.some(account => !exceptIds?.has(account.id))) {
212
+ throw new Error(agent === undefined
213
+ ? `Account '${name}' already exists.`
214
+ : `Account '${name}' already exists for the ${agent} harness.`);
215
+ }
195
216
  // Same laziness as findUnifiedAccount: a native row that already owns this
196
217
  // name (even one we are mutating) means we never open the provider store.
197
218
  if (nativeHits.length > 0)
@@ -203,7 +224,7 @@ function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
203
224
  export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
204
225
  assertNativeLabel(name);
205
226
  const meta = readMeta();
206
- assertUniqueUnifiedName(name, meta);
227
+ assertUniqueUnifiedName(name, meta, undefined, undefined, agent);
207
228
  const duplicate = listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey);
208
229
  if (duplicate)
209
230
  throw new Error(`This ${agent} login is already named '${duplicate.name}'.`);
@@ -240,7 +261,7 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
240
261
  assertNativeLabel(resolvedLabel);
241
262
  const meta = readMeta();
242
263
  const matches = nativeIdentityRows(meta, agent, identityKey);
243
- assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)));
264
+ assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)), agent);
244
265
  if (matches.length === 0)
245
266
  return addNativeAccount(resolvedLabel, agent, identityKey, identityLabel, scope);
246
267
  // Sweep every row for this identity (PHNX-3206), routing the whole sweep to the
@@ -50,6 +50,8 @@ export interface RotateCandidate {
50
50
  * does not gate — a stale/absent probe never blocks a launch.
51
51
  */
52
52
  authVerdict: AuthVerdict | null;
53
+ /** Epoch milliseconds of the auth probe behind authVerdict, when present. */
54
+ authCheckedAt?: number | null;
53
55
  lastActive: Date | null;
54
56
  /**
55
57
  * Set only for a candidate that comes from a provider account bundle rather
@@ -243,7 +245,7 @@ export type AccountReadiness = {
243
245
  * status — matching the gate — so a stale `out_of_credits` cache is not
244
246
  * reported while the account is actually serving requests.
245
247
  */
246
- export declare function readinessFromCandidate(candidate: RotateCandidate): AccountReadiness;
248
+ export declare function readinessFromCandidate(candidate: RotateCandidate, now?: number): AccountReadiness;
247
249
  /**
248
250
  * Whether a human sitting at a terminal can clear this exclusion by launching
249
251
  * the agent and signing in. The two unhealthy classes are opposites, and the
@@ -15,7 +15,7 @@ import { emit } from '../feed/events.js';
15
15
  import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
16
16
  import { readAccountHeadroom } from '../fleet-cache.js';
17
17
  import { machineId } from '../machine-id.js';
18
- import { readAuthHealthCache, authCacheKey, isDeadVerdict } from '../auth-health.js';
18
+ import { AUTH_PROBE_MAX_AGE_MS, readAuthHealthCache, authCacheKey, isDeadVerdict } from '../auth-health.js';
19
19
  function getRotateDir() {
20
20
  const dir = path.join(getHelpersDir(), 'rotate');
21
21
  fs.mkdirSync(dir, { recursive: true });
@@ -229,7 +229,7 @@ function hasUsageAvailable(candidate) {
229
229
  * status — matching the gate — so a stale `out_of_credits` cache is not
230
230
  * reported while the account is actually serving requests.
231
231
  */
232
- export function readinessFromCandidate(candidate) {
232
+ export function readinessFromCandidate(candidate, now = Date.now()) {
233
233
  if (!candidate.signedIn) {
234
234
  return { ready: false, reason: 'signed_out', email: candidate.email };
235
235
  }
@@ -237,7 +237,9 @@ export function readinessFromCandidate(candidate) {
237
237
  // auth at spawn no matter how much usage headroom it has. Exclude it BEFORE the
238
238
  // usage gate so rotation never routes into a doomed login. Fail-open: any other
239
239
  // (or null) verdict does not gate — see `RotateCandidate.authVerdict`.
240
- if (candidate.authVerdict !== null && isDeadVerdict(candidate.authVerdict)) {
240
+ const authFresh = candidate.authCheckedAt == null
241
+ || now - candidate.authCheckedAt <= AUTH_PROBE_MAX_AGE_MS;
242
+ if (authFresh && candidate.authVerdict !== null && isDeadVerdict(candidate.authVerdict)) {
241
243
  return { ready: false, reason: 'revoked', email: candidate.email };
242
244
  }
243
245
  if (hasUsageAvailable(candidate)) {
@@ -697,7 +699,8 @@ export async function collectRunCandidates(agent) {
697
699
  // lives — see isLaunchableSignedIn. Do not reuse the active-home fallback
698
700
  // identity for routing, or empty version homes look healthy and die at spawn.
699
701
  const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
700
- const authVerdict = authCache[authCacheKey(localHost, agent, version)]?.verdict ?? null;
702
+ const authHealth = authCache[authCacheKey(localHost, agent, version)];
703
+ const authVerdict = authHealth?.verdict ?? null;
701
704
  return {
702
705
  agent,
703
706
  version,
@@ -710,6 +713,7 @@ export async function collectRunCandidates(agent) {
710
713
  plan: launchable ? info.plan : null,
711
714
  signedIn: launchable,
712
715
  authVerdict,
716
+ authCheckedAt: authHealth?.checkedAt ?? null,
713
717
  lastActive: info.lastActive,
714
718
  };
715
719
  }));
@@ -25,6 +25,8 @@ export interface AuthHealth {
25
25
  /** account label for display (email / id), when known. Never part of the key. */
26
26
  account?: string;
27
27
  }
28
+ /** Maximum age of an auth verdict used for automatic routing decisions. */
29
+ export declare const AUTH_PROBE_MAX_AGE_MS: number;
28
30
  /** Agents with a live network probe wired up today. The rest are best-effort. */
29
31
  export declare const LIVE_PROBE_AGENTS: ReadonlySet<AgentId>;
30
32
  /** Map an HTTP status from a live probe to a verdict. */
@@ -23,6 +23,8 @@ import { getCacheDir } from './state.js';
23
23
  import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
24
24
  import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
25
25
  import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
26
+ /** Maximum age of an auth verdict used for automatic routing decisions. */
27
+ export const AUTH_PROBE_MAX_AGE_MS = 20 * 60_000;
26
28
  /** Agents with a live network probe wired up today. The rest are best-effort. */
27
29
  export const LIVE_PROBE_AGENTS = new Set(['claude', 'kimi', 'droid']);
28
30
  // ---------------------------------------------------------------------------
@@ -90,6 +90,27 @@ export declare function ensureProfilePreferences(userDataDir: string, profileNam
90
90
  */
91
91
  export declare function isPortInUse(port: number): boolean;
92
92
  export declare function allocatePort(): number;
93
+ /**
94
+ * Read the `--user-data-dir` a running browser process was launched with, by
95
+ * inspecting its command line (PHNX-3967). This is the ownership signal the
96
+ * attach-only guard uses to tell the credentialed canonical browser apart from a
97
+ * foreign port-squatter serving CDP on the same port (e.g. a logged-out
98
+ * `/tmp/...` Comet). Returns null when the pid is gone, exposes no
99
+ * `--user-data-dir`, or the platform probe fails.
100
+ *
101
+ * POSIX: `ps -ww -o command=` (full, un-truncated argv). Windows: the
102
+ * `CommandLine` from `Win32_Process` via PowerShell. The value is captured up to
103
+ * the next ` --<flag>` (or end of line) so a data dir that itself contains a
104
+ * space is not truncated at the space.
105
+ */
106
+ export declare function getProcessUserDataDir(pid: number): string | null;
107
+ /**
108
+ * Extract the `--user-data-dir` value from a browser command line. Handles both
109
+ * `--user-data-dir=<path>` and `--user-data-dir <path>` and stops the value at
110
+ * the next ` --<flag>` so a path containing spaces survives. Exported for unit
111
+ * testing without a live process.
112
+ */
113
+ export declare function parseUserDataDirFromCommandLine(cmdline: string): string | null;
93
114
  export interface PortOccupant {
94
115
  pid: number;
95
116
  command: string;
@@ -277,9 +277,15 @@ isElectron = false) {
277
277
  // *inside* each user-data-dir. Direct binary spawn with a fresh
278
278
  // --user-data-dir creates a fully independent process — the user's
279
279
  // normal browser (running under their default user-data-dir) and our
280
- // sandboxed one coexist as two real processes. The macOS Dock collapses
281
- // them into one icon per .app bundle, which makes it look like a single
282
- // instance, but `ps -ww` will show both.
280
+ // sandboxed one coexist as two real processes.
281
+ //
282
+ // These are TWO dock tiles, not one. Measured on macOS 26 (zion, 2026-09-04):
283
+ // a second Comet spawned this way registers its OWN LaunchServices ASN, so
284
+ // `lsappinfo list` reports two `ai.perplexity.comet` entries and the Dock shows
285
+ // two Comet icons — one of them the logged-out sandbox. The Dock does NOT
286
+ // collapse same-bundle processes into one tile. Spawning a rival window is
287
+ // exactly the failure PHNX-3967 set out to end, which is why an attach-only
288
+ // profile never reaches this launcher (see connectLocal / isAttachOnlyProfile).
283
289
  const viewport = options.viewport ?? { width: 1512, height: 982 };
284
290
  const args = [
285
291
  '--remote-debugging-pipe',
@@ -479,6 +485,57 @@ export function allocatePort() {
479
485
  }
480
486
  throw new Error('No available ports in range 9200-9300');
481
487
  }
488
+ /**
489
+ * Read the `--user-data-dir` a running browser process was launched with, by
490
+ * inspecting its command line (PHNX-3967). This is the ownership signal the
491
+ * attach-only guard uses to tell the credentialed canonical browser apart from a
492
+ * foreign port-squatter serving CDP on the same port (e.g. a logged-out
493
+ * `/tmp/...` Comet). Returns null when the pid is gone, exposes no
494
+ * `--user-data-dir`, or the platform probe fails.
495
+ *
496
+ * POSIX: `ps -ww -o command=` (full, un-truncated argv). Windows: the
497
+ * `CommandLine` from `Win32_Process` via PowerShell. The value is captured up to
498
+ * the next ` --<flag>` (or end of line) so a data dir that itself contains a
499
+ * space is not truncated at the space.
500
+ */
501
+ export function getProcessUserDataDir(pid) {
502
+ if (!pid || pid <= 0)
503
+ return null;
504
+ let cmdline = '';
505
+ try {
506
+ if (process.platform === 'win32') {
507
+ cmdline = execFileSync('powershell', [
508
+ '-NoProfile',
509
+ '-Command',
510
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`,
511
+ ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
512
+ }
513
+ else {
514
+ cmdline = execFileSync('ps', ['-ww', '-o', 'command=', '-p', String(pid)], {
515
+ encoding: 'utf8',
516
+ stdio: ['ignore', 'pipe', 'ignore'],
517
+ });
518
+ }
519
+ }
520
+ catch {
521
+ return null;
522
+ }
523
+ return parseUserDataDirFromCommandLine(cmdline);
524
+ }
525
+ /**
526
+ * Extract the `--user-data-dir` value from a browser command line. Handles both
527
+ * `--user-data-dir=<path>` and `--user-data-dir <path>` and stops the value at
528
+ * the next ` --<flag>` so a path containing spaces survives. Exported for unit
529
+ * testing without a live process.
530
+ */
531
+ export function parseUserDataDirFromCommandLine(cmdline) {
532
+ const line = cmdline.replace(/\r?\n/g, ' ').trim();
533
+ const m = line.match(/--user-data-dir[=\s]+(.*?)(?=\s--[A-Za-z]|$)/);
534
+ if (!m)
535
+ return null;
536
+ const value = m[1].trim().replace(/^["']|["']$/g, '');
537
+ return value.length > 0 ? value : null;
538
+ }
482
539
  /**
483
540
  * Identify the process listening on a TCP port. Returns null when nothing is bound.
484
541
  * Used for clearer error messages when a profile's configured port is taken by a
@@ -16,6 +16,27 @@ export interface LocalConnection {
16
16
  * duplicate. Exported so the contract is unit-testable without a real Arc.
17
17
  */
18
18
  export declare function arcAttachRequiredError(profileName: string, port: number): Error;
19
+ /**
20
+ * The loud error an ATTACH-ONLY profile raises when nothing debuggable is on its
21
+ * port (PHNX-3967). Same contract as {@link arcAttachRequiredError}, generalized
22
+ * to any browser: agents attach to a browser the user already started and NEVER
23
+ * spawn a rival window, so instead of falling through to `launchBrowser` (which
24
+ * would produce the second, logged-out dock tile this ticket set out to end) we
25
+ * fail loud with the exact relaunch that makes the canonical instance
26
+ * attachable — pinned to the profile's DURABLE `--user-data-dir` so the one-time
27
+ * sign-in persists. Exported so the contract is unit-testable without a browser.
28
+ */
29
+ export declare function attachOnlyRequiredError(profile: Pick<BrowserProfile, 'name' | 'browser' | 'userDataDir'>, port: number): Error;
30
+ /**
31
+ * The loud error an attach-only profile raises when a browser IS serving CDP on
32
+ * its port but is a FOREIGN instance — its `--user-data-dir` is not the profile's
33
+ * durable dir (PHNX-3967). This closes the port-squat: a logged-out `/tmp/...`
34
+ * Comet answering CDP on the canonical port passes the browser-FAMILY identity
35
+ * check, so without an ownership check agents would drive it as if it were the
36
+ * credentialed browser. Rather than adopt the squatter we reject it and name the
37
+ * fix. Exported for unit testing.
38
+ */
39
+ export declare function foreignInstanceError(profile: Pick<BrowserProfile, 'name' | 'browser' | 'userDataDir'>, port: number, runningDataDir: string, pid: number): Error;
19
40
  export declare function connectLocal(endpoint: string, profile: BrowserProfile,
20
41
  /**
21
42
  * Runtime key (`<profile>@<endpoint>`) the launched browser's user-data-dir,
@@ -1,7 +1,7 @@
1
1
  import * as net from 'net';
2
2
  import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.js';
3
- import { launchBrowser, getPortOccupant } from '../chrome.js';
4
- import { parseEndpointUrl } from '../profiles.js';
3
+ import { launchBrowser, getPortOccupant, getProcessUserDataDir } from '../chrome.js';
4
+ import { parseEndpointUrl, isAttachOnlyProfile, resolveProfileDataDir, normalizeDataDir } from '../profiles.js';
5
5
  /**
6
6
  * Cheap TCP-level "is something bound here?" probe. Used as a fallback when
7
7
  * `getPortOccupant()` (lsof-based) misses the process — Comet and other
@@ -49,6 +49,89 @@ export function arcAttachRequiredError(profileName, port) {
49
49
  ` open -a Arc --args --remote-debugging-port=${port}\n` +
50
50
  `and retry. The port must match the profile's endpoint (\`agents browser profiles list\`).`);
51
51
  }
52
+ /**
53
+ * The loud error an ATTACH-ONLY profile raises when nothing debuggable is on its
54
+ * port (PHNX-3967). Same contract as {@link arcAttachRequiredError}, generalized
55
+ * to any browser: agents attach to a browser the user already started and NEVER
56
+ * spawn a rival window, so instead of falling through to `launchBrowser` (which
57
+ * would produce the second, logged-out dock tile this ticket set out to end) we
58
+ * fail loud with the exact relaunch that makes the canonical instance
59
+ * attachable — pinned to the profile's DURABLE `--user-data-dir` so the one-time
60
+ * sign-in persists. Exported so the contract is unit-testable without a browser.
61
+ */
62
+ export function attachOnlyRequiredError(profile, port) {
63
+ if (profile.browser === 'arc')
64
+ return arcAttachRequiredError(profile.name, port);
65
+ const app = profile.browser === 'comet' ? 'Comet' : profile.browser;
66
+ const dataDir = resolveProfileDataDir(profile);
67
+ return new Error(`Profile "${profile.name}" is attach-only and nothing is serving the Chrome ` +
68
+ `DevTools Protocol on cdp://127.0.0.1:${port}. agents browser attaches to the ` +
69
+ `${app} you already started and never launches a second one, so it will not spawn ` +
70
+ `a rival window (that is the duplicate, logged-out tile this profile exists to ` +
71
+ `prevent). Launch the canonical ${app} with remote debugging on its durable data dir:\n` +
72
+ ` open -a ${app} --args --remote-debugging-port=${port} --user-data-dir=${dataDir}\n` +
73
+ `and retry. Keep signing in once in that window — the data dir is durable, so the ` +
74
+ `login survives quit+relaunch.`);
75
+ }
76
+ /**
77
+ * The loud error an attach-only profile raises when a browser IS serving CDP on
78
+ * its port but is a FOREIGN instance — its `--user-data-dir` is not the profile's
79
+ * durable dir (PHNX-3967). This closes the port-squat: a logged-out `/tmp/...`
80
+ * Comet answering CDP on the canonical port passes the browser-FAMILY identity
81
+ * check, so without an ownership check agents would drive it as if it were the
82
+ * credentialed browser. Rather than adopt the squatter we reject it and name the
83
+ * fix. Exported for unit testing.
84
+ */
85
+ export function foreignInstanceError(profile, port, runningDataDir, pid) {
86
+ const expected = resolveProfileDataDir(profile);
87
+ const app = profile.browser === 'comet' ? 'Comet' : profile.browser;
88
+ return new Error(`Attach-only ownership check failed for profile "${profile.name}" on ` +
89
+ `cdp://127.0.0.1:${port}: a ${app} is serving CDP there (pid ${pid}) but it is ` +
90
+ `running a FOREIGN user-data-dir\n` +
91
+ ` running: ${runningDataDir}\n` +
92
+ ` expected: ${expected}\n` +
93
+ `so it is not this profile's credentialed browser. agents will not drive it. Close ` +
94
+ `that instance (\`kill ${pid}\`), then relaunch the canonical ${app}:\n` +
95
+ ` open -a ${app} --args --remote-debugging-port=${port} --user-data-dir=${expected}`);
96
+ }
97
+ /**
98
+ * Prefix every ownership-rejection message carries, so `connectLocal`'s catch can
99
+ * re-throw it verbatim instead of misreading it as "attach failed, launch fresh".
100
+ */
101
+ const OWNERSHIP_REJECTION_PREFIX = 'Attach-only ownership check failed';
102
+ /**
103
+ * Verify the browser serving CDP on `port` belongs to this attach-only profile,
104
+ * by comparing its live `--user-data-dir` to the profile's durable dir
105
+ * (PHNX-3967). No-op for a `launch`-policy profile. When an occupant IS present
106
+ * but its data dir can't be read, fail LOUD rather than open: a canonical Comet
107
+ * this profile is meant to attach to was launched by the user with the durable
108
+ * `--user-data-dir` and is readable via `ps`, so an unreadable occupant is the
109
+ * suspicious case, not a legitimate one — driving it would be the exact
110
+ * port-squat this guard exists to stop.
111
+ */
112
+ function verifyEndpointOwnership(profile, port) {
113
+ if (!isAttachOnlyProfile(profile))
114
+ return;
115
+ // Arc is the user's single running instance under its own default data dir; it
116
+ // has no managed durable dir to compare against and no /tmp-squat vector.
117
+ if (profile.browser === 'arc')
118
+ return;
119
+ const occupant = getPortOccupant(port);
120
+ if (!occupant)
121
+ return;
122
+ const expected = resolveProfileDataDir(profile);
123
+ const runningDataDir = getProcessUserDataDir(occupant.pid);
124
+ if (!runningDataDir) {
125
+ throw new Error(`${OWNERSHIP_REJECTION_PREFIX} for profile "${profile.name}" on cdp://127.0.0.1:${port}: ` +
126
+ `a process (pid ${occupant.pid}) is serving CDP there but its --user-data-dir could not be ` +
127
+ `read, so ownership can't be confirmed. Refusing to attach to an unverified instance. If ` +
128
+ `this is your canonical browser, relaunch it with --user-data-dir=${expected} so the ` +
129
+ `attach-only guard can verify it.`);
130
+ }
131
+ if (normalizeDataDir(runningDataDir) !== normalizeDataDir(expected)) {
132
+ throw foreignInstanceError(profile, port, runningDataDir, occupant.pid);
133
+ }
134
+ }
52
135
  export async function connectLocal(endpoint, profile,
53
136
  /**
54
137
  * Runtime key (`<profile>@<endpoint>`) the launched browser's user-data-dir,
@@ -81,6 +164,11 @@ key) {
81
164
  try {
82
165
  const { wsUrl, browser } = await discoverBrowserWsUrl(port, 'localhost', profile.name);
83
166
  verifyBrowserIdentity(browser, profile.browser, port);
167
+ // Ownership beyond browser FAMILY (PHNX-3967): a foreign /tmp Comet answers
168
+ // CDP on the canonical port and passes verifyBrowserIdentity, so before we
169
+ // adopt the endpoint confirm its --user-data-dir is this profile's durable
170
+ // dir. Rejects the port-squatter loudly instead of driving it.
171
+ verifyEndpointOwnership(profile, port);
84
172
  const cdp = new CDPClient();
85
173
  await cdp.connect(wsUrl);
86
174
  return { cdp, port, pid: 0 };
@@ -89,13 +177,18 @@ key) {
89
177
  if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
90
178
  throw err;
91
179
  }
92
- // Arc reached the catch, so the attach above failed: the running Arc is not
93
- // serving CDP on this port. Never fall through to launchBrowser — that would
94
- // spawn the duplicate/stray-window this ticket set out to end (#2779,
95
- // PHNX-2399). Fail loud with the relaunch that makes the user's Arc
96
- // attachable.
97
- if (profile.browser === 'arc') {
98
- throw arcAttachRequiredError(profile.name, port);
180
+ // An ownership rejection is a definitive answer — never fall through to a
181
+ // fresh launch or a generic port message. Re-throw it verbatim.
182
+ if (err instanceof Error && err.message.startsWith(OWNERSHIP_REJECTION_PREFIX)) {
183
+ throw err;
184
+ }
185
+ // An attach-only profile reached the catch, so the attach above failed:
186
+ // nothing debuggable is on this port. Never fall through to launchBrowser —
187
+ // that would spawn the duplicate/stray-window this ticket set out to end
188
+ // (Arc: #2779, PHNX-2399; Comet: PHNX-3967). Fail loud with the relaunch that
189
+ // makes the canonical instance attachable.
190
+ if (isAttachOnlyProfile(profile)) {
191
+ throw attachOnlyRequiredError(profile, port);
99
192
  }
100
193
  // Distinguish "nothing listening on this port" (fine to launch fresh) from
101
194
  // "something is listening but it's not a debuggable browser" (bail loudly —
@@ -44,6 +44,34 @@ export type BrowserProfileWithDeclarations = BrowserProfile & {
44
44
  export declare function getConfiguredDefaultProfileName(): string | undefined;
45
45
  export declare function getBrowserRuntimeDir(): string;
46
46
  export declare function getProfileRuntimeDir(name: string): string;
47
+ /**
48
+ * True when a profile attaches to a browser the user already launched and MUST
49
+ * NOT spawn its own (PHNX-3967). Arc is inherently attach-only — it is
50
+ * single-instance and relaunching it just produces a stray window, never a
51
+ * second debuggable process (issue #2779) — regardless of an explicit policy.
52
+ * Any other browser is attach-only only when the profile says so.
53
+ */
54
+ export declare function isAttachOnlyProfile(profile: Pick<BrowserProfile, 'browser' | 'launchPolicy'>): boolean;
55
+ /**
56
+ * The DURABLE `--user-data-dir` for a profile (PHNX-3967). An explicit
57
+ * `profile.userDataDir` wins; otherwise a per-profile dir under
58
+ * `~/.agents/.history/browser-profiles/<name>/chrome-data`, which is outside
59
+ * `~/.agents/.cache` so a one-time sign-in survives quit+relaunch and the
60
+ * `profiles remove` cache sweep. This is the dir the canonical Comet is launched
61
+ * with and the value the ownership guard compares a running instance against.
62
+ *
63
+ * Note this is distinct from {@link getProfileRuntimeDir}'s cache `chrome-data`,
64
+ * which `launchBrowser` uses for a `launch`-policy profile it spawns itself.
65
+ */
66
+ export declare function resolveProfileDataDir(profile: Pick<BrowserProfile, 'name' | 'userDataDir'>): string;
67
+ /**
68
+ * Normalize a `--user-data-dir` path for the attach-only ownership comparison
69
+ * (PHNX-3967): the real path when it resolves on this box, else the literal with
70
+ * trailing slashes stripped. The single source both the runtime guard
71
+ * (`drivers/local.ts` `verifyEndpointOwnership`) and `browser profiles doctor`
72
+ * use, so they can never disagree about whether a running instance is foreign.
73
+ */
74
+ export declare function normalizeDataDir(dir: string): string;
47
75
  /**
48
76
  * Default destination for browser downloads for a profile. Set browser-global at
49
77
  * connect time (see BrowserService), so downloads land here even when the agent
@@ -217,7 +245,7 @@ export declare function updateProfile(profile: BrowserProfile): Promise<void>;
217
245
  * ({@link getProfileRuntimeDir}) and any live `<name>@<endpoint>` connection, so
218
246
  * changing either orphans the cached browser data. Delete and recreate instead.
219
247
  */
220
- export type EditableProfileFields = Partial<Pick<BrowserProfile, 'description' | 'binary' | 'electron' | 'targetFilter' | 'endpoints' | 'chrome' | 'secrets' | 'viewport'>>;
248
+ export type EditableProfileFields = Partial<Pick<BrowserProfile, 'description' | 'binary' | 'electron' | 'targetFilter' | 'endpoints' | 'launchPolicy' | 'userDataDir' | 'chrome' | 'secrets' | 'viewport'>>;
221
249
  export interface EditProfileResult {
222
250
  profile: BrowserProfile;
223
251
  devices: string[];
@@ -1,6 +1,6 @@
1
1
  import * as path from 'path';
2
2
  import * as fs from 'fs';
3
- import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, readMeta, updateMeta, } from '../state.js';
3
+ import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, getBrowserDurableDir, readMeta, updateMeta, } from '../state.js';
4
4
  import { getConfigValue } from '../device-config.js';
5
5
  import { machineId } from '../machine-id.js';
6
6
  import { declaringDevices, profileRegistry, } from './registry.js';
@@ -51,6 +51,49 @@ export function getBrowserRuntimeDir() {
51
51
  export function getProfileRuntimeDir(name) {
52
52
  return path.join(getBrowserRuntimeDir(), name);
53
53
  }
54
+ /**
55
+ * True when a profile attaches to a browser the user already launched and MUST
56
+ * NOT spawn its own (PHNX-3967). Arc is inherently attach-only — it is
57
+ * single-instance and relaunching it just produces a stray window, never a
58
+ * second debuggable process (issue #2779) — regardless of an explicit policy.
59
+ * Any other browser is attach-only only when the profile says so.
60
+ */
61
+ export function isAttachOnlyProfile(profile) {
62
+ return profile.browser === 'arc' || profile.launchPolicy === 'attach-only';
63
+ }
64
+ /**
65
+ * The DURABLE `--user-data-dir` for a profile (PHNX-3967). An explicit
66
+ * `profile.userDataDir` wins; otherwise a per-profile dir under
67
+ * `~/.agents/.history/browser-profiles/<name>/chrome-data`, which is outside
68
+ * `~/.agents/.cache` so a one-time sign-in survives quit+relaunch and the
69
+ * `profiles remove` cache sweep. This is the dir the canonical Comet is launched
70
+ * with and the value the ownership guard compares a running instance against.
71
+ *
72
+ * Note this is distinct from {@link getProfileRuntimeDir}'s cache `chrome-data`,
73
+ * which `launchBrowser` uses for a `launch`-policy profile it spawns itself.
74
+ */
75
+ export function resolveProfileDataDir(profile) {
76
+ if (profile.userDataDir)
77
+ return profile.userDataDir;
78
+ return path.join(getBrowserDurableDir(), profile.name, 'chrome-data');
79
+ }
80
+ /**
81
+ * Normalize a `--user-data-dir` path for the attach-only ownership comparison
82
+ * (PHNX-3967): the real path when it resolves on this box, else the literal with
83
+ * trailing slashes stripped. The single source both the runtime guard
84
+ * (`drivers/local.ts` `verifyEndpointOwnership`) and `browser profiles doctor`
85
+ * use, so they can never disagree about whether a running instance is foreign.
86
+ */
87
+ export function normalizeDataDir(dir) {
88
+ let out = dir;
89
+ try {
90
+ out = fs.realpathSync(dir);
91
+ }
92
+ catch {
93
+ /* dir may not exist on this box; compare the literal */
94
+ }
95
+ return out.replace(/\/+$/, '');
96
+ }
54
97
  /**
55
98
  * Default destination for browser downloads for a profile. Set browser-global at
56
99
  * connect time (see BrowserService), so downloads land here even when the agent
@@ -79,6 +122,8 @@ function configToProfile(name, config, devices = []) {
79
122
  targetFilter: config.targetFilter,
80
123
  endpoints: config.endpoints,
81
124
  defaultEndpoint: config.defaultEndpoint,
125
+ launchPolicy: config.launchPolicy,
126
+ userDataDir: config.userDataDir,
82
127
  chrome: config.chrome,
83
128
  secrets: config.secrets,
84
129
  viewport: config.viewport,
@@ -103,6 +148,10 @@ function profileToConfig(profile) {
103
148
  config.targetFilter = profile.targetFilter;
104
149
  if (profile.defaultEndpoint)
105
150
  config.defaultEndpoint = profile.defaultEndpoint;
151
+ if (profile.launchPolicy)
152
+ config.launchPolicy = profile.launchPolicy;
153
+ if (profile.userDataDir)
154
+ config.userDataDir = profile.userDataDir;
106
155
  if (profile.chrome)
107
156
  config.chrome = profile.chrome;
108
157
  if (profile.secrets)
@@ -109,6 +109,24 @@ export interface BrowserProfile {
109
109
  */
110
110
  endpoints: string[] | Record<string, EndpointPreset>;
111
111
  defaultEndpoint?: string;
112
+ /**
113
+ * How agents obtain a live browser for this profile (PHNX-3967):
114
+ * - `launch` (default when absent): spawn the browser under a managed
115
+ * `--user-data-dir` when nothing is serving CDP on the port.
116
+ * - `attach-only`: NEVER spawn a rival window — attach to a browser the user
117
+ * already started with remote debugging, else fail loud with a relaunch
118
+ * hint. Arc is inherently attach-only; a canonical signed-in Comet uses it.
119
+ * Pairs with a durable {@link userDataDir}.
120
+ */
121
+ launchPolicy?: 'attach-only' | 'launch';
122
+ /**
123
+ * Absolute durable `--user-data-dir` for this profile (PHNX-3967). When absent,
124
+ * an attach-only profile resolves a default durable dir outside `.cache` so a
125
+ * one-time sign-in survives relaunch. The value the ownership guard compares a
126
+ * running instance against to reject a port-squatter. Resolve via
127
+ * `resolveProfileDataDir(profile)` rather than reading directly.
128
+ */
129
+ userDataDir?: string;
112
130
  chrome?: ChromeOptions;
113
131
  secrets?: string;
114
132
  viewport?: {
@@ -25,10 +25,15 @@ export declare function resolveTcpEndpoint(): {
25
25
  port: number;
26
26
  token: string | null;
27
27
  } | null;
28
+ export declare function resolveVncEndpoint(): {
29
+ host: string;
30
+ port: number;
31
+ password: string;
32
+ } | null;
28
33
  export declare function openComputerClient(): ComputerClient;
29
34
  export declare const RPC_TIMEOUT_MS = 30000;
30
35
  export declare function resolveRpcTimeoutMs(env: string | undefined): number;
31
36
  export declare function describeTransport(): {
32
- kind: 'socket' | 'stdio' | 'tcp' | 'none';
37
+ kind: 'socket' | 'stdio' | 'tcp' | 'vnc' | 'none';
33
38
  path: string | null;
34
39
  };