negotium 0.1.37 → 0.1.39

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 (64) hide show
  1. package/README.md +19 -0
  2. package/dist/agent-helpers.js +15637 -5674
  3. package/dist/agent-helpers.js.map +85 -37
  4. package/dist/browser-runtime.js +1600 -0
  5. package/dist/browser-runtime.js.map +28 -0
  6. package/dist/hosted-agent.js +8 -8
  7. package/dist/hosted-agent.js.map +6 -5
  8. package/dist/main.js +3790 -3113
  9. package/dist/main.js.map +33 -29
  10. package/dist/mcp-factories.js +15141 -301
  11. package/dist/mcp-factories.js.map +100 -7
  12. package/dist/prompts.js +117 -49
  13. package/dist/prompts.js.map +3 -3
  14. package/dist/query-runtime.js +310 -1
  15. package/dist/query-runtime.js.map +10 -5
  16. package/dist/runtime/src/agents/archiver.ts +407 -296
  17. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +440 -274
  18. package/dist/runtime/src/agents/mcp-tools/self-config.ts +139 -65
  19. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +1037 -816
  20. package/dist/runtime/src/agents/public-helpers.ts +82 -0
  21. package/dist/runtime/src/agents/self-config-core.ts +574 -278
  22. package/dist/runtime/src/agents/topic-cleanup.ts +167 -129
  23. package/dist/runtime/src/index.ts +4 -1
  24. package/dist/runtime/src/mcp/factories/index.ts +9 -0
  25. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +132 -0
  26. package/dist/runtime/src/mcp/session-comm/topics.ts +23 -57
  27. package/dist/runtime/src/platform/mcp-config.ts +2 -4
  28. package/dist/runtime/src/platform/playwright/browser-processes.ts +3 -2
  29. package/dist/runtime/src/platform/playwright/capability.ts +5 -0
  30. package/dist/runtime/src/platform/playwright/manager.ts +380 -66
  31. package/dist/runtime/src/platform/playwright/public-runtime.ts +45 -0
  32. package/dist/runtime/src/platform/playwright/transport-probe.ts +89 -0
  33. package/dist/runtime/src/prompts/builders.ts +192 -51
  34. package/dist/runtime/src/query/active-rooms.ts +2 -0
  35. package/dist/runtime/src/query/public-runtime.ts +8 -0
  36. package/dist/runtime/src/query/state.ts +69 -33
  37. package/dist/runtime/src/query/types.ts +1 -0
  38. package/dist/runtime/src/runtime/playwright-turn-abort.ts +59 -0
  39. package/dist/runtime/src/runtime/turn-event-stream.ts +23 -6
  40. package/dist/runtime/src/runtime/turn-runner.ts +6 -1
  41. package/dist/runtime/src/version.ts +1 -1
  42. package/dist/types/packages/core/src/agents/api-topic-agent-switch.d.ts +38 -0
  43. package/dist/types/packages/core/src/agents/archiver.d.ts +52 -16
  44. package/dist/types/packages/core/src/agents/mcp-tools/ask-user.d.ts +75 -4
  45. package/dist/types/packages/core/src/agents/mcp-tools/self-config.d.ts +17 -0
  46. package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +112 -25
  47. package/dist/types/packages/core/src/agents/public-helpers.d.ts +7 -0
  48. package/dist/types/packages/core/src/agents/self-config-core.d.ts +185 -0
  49. package/dist/types/packages/core/src/agents/topic-cleanup.d.ts +30 -24
  50. package/dist/types/packages/core/src/mcp/factories/index.d.ts +1 -0
  51. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +46 -0
  52. package/dist/types/packages/core/src/platform/mcp-config.d.ts +2 -1
  53. package/dist/types/packages/core/src/platform/playwright/capability.d.ts +1 -0
  54. package/dist/types/packages/core/src/platform/playwright/manager.d.ts +65 -5
  55. package/dist/types/packages/core/src/platform/playwright/public-runtime.d.ts +4 -0
  56. package/dist/types/packages/core/src/platform/playwright/transport-probe.d.ts +13 -0
  57. package/dist/types/packages/core/src/prompts/builders.d.ts +29 -3
  58. package/dist/types/packages/core/src/query/active-rooms.d.ts +2 -0
  59. package/dist/types/packages/core/src/query/public-runtime.d.ts +1 -0
  60. package/dist/types/packages/core/src/query/state.d.ts +13 -0
  61. package/dist/types/packages/core/src/query/types.d.ts +2 -1
  62. package/dist/types/packages/core/src/runtime/playwright-turn-abort.d.ts +12 -0
  63. package/dist/types/packages/core/src/version.d.ts +1 -1
  64. package/package.json +6 -1
@@ -1,5 +1,5 @@
1
1
  import { type ChildProcess, execFileSync, spawn } from "node:child_process";
2
- import { createHash, randomBytes } from "node:crypto";
2
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import {
4
4
  cpSync,
5
5
  existsSync,
@@ -14,6 +14,7 @@ import { dirname, join, resolve } from "node:path";
14
14
  import {
15
15
  BROWSER_PROFILES_DIR,
16
16
  BROWSER_RS_BIN,
17
+ type BrowserProxyConfig,
17
18
  PATCHRIGHT_MCP_BIN,
18
19
  PLAYWRIGHT_BASE_PORT,
19
20
  PLAYWRIGHT_MAX_PORT,
@@ -36,6 +37,7 @@ import {
36
37
  type HeadedPlaywrightSpawnSpec,
37
38
  resolveHeadedPlaywrightSpawn,
38
39
  } from "#platform/playwright/headed-launch";
40
+ import { probePlaywrightMcpTransports } from "#platform/playwright/transport-probe";
39
41
  import { sanitizeTopicName } from "#security/sanitize";
40
42
  import {
41
43
  assignTopicBrowserProfile,
@@ -51,11 +53,48 @@ import {
51
53
  waitForChildProcessSpawnError,
52
54
  } from "./manager-utils";
53
55
 
56
+ export interface PlaywrightProfileBinding {
57
+ readonly instanceKey: string;
58
+ readonly ownerId: string;
59
+ readonly profile: string;
60
+ }
61
+
62
+ export interface PlaywrightChildEnvironmentContext {
63
+ instanceKey: string;
64
+ ownerId: string;
65
+ capability: string;
66
+ proxy: BrowserProxyConfig | null;
67
+ browserRsBin?: string;
68
+ environment: NodeJS.ProcessEnv;
69
+ }
70
+
71
+ export interface PlaywrightManagerHost {
72
+ readonly portsDir: string;
73
+ readonly basePort: number;
74
+ readonly maxPort: number;
75
+ readonly browserBin: string;
76
+ readonly fallbackBrowserBin: string;
77
+ readonly browserRsBin?: string;
78
+ readonly resolveProxy: () => BrowserProxyConfig | null;
79
+ readonly resolveTopicBinding: (userId: string, topic?: string) => PlaywrightProfileBinding;
80
+ readonly resolveNamedBinding: (ownerId: string, rawProfile: string) => PlaywrightProfileBinding;
81
+ readonly resolveInstanceDataDir: (instanceKey: string) => string;
82
+ readonly createChildEnvironment: (
83
+ context: PlaywrightChildEnvironmentContext,
84
+ ) => NodeJS.ProcessEnv;
85
+ /** Reap an orphan that owns this exact host-managed data directory. */
86
+ readonly cleanupBrowserProcessesForDataDir: (userDataDir: string) => void;
87
+ /** Reap host-managed browsers that are not represented by the supplied live directories. */
88
+ readonly reapOrphanBrowsers: (liveUserDataDirs: Iterable<string>) => void;
89
+ }
90
+
54
91
  export {
55
92
  cleanupZombiePlaywright,
56
93
  isBrowserJanitorOwner,
94
+ reapOrphanBrowsers,
57
95
  selectOrphanBrowserPids,
58
96
  } from "#platform/playwright/browser-processes";
97
+ export { probePlaywrightMcpTransports } from "#platform/playwright/transport-probe";
59
98
  export {
60
99
  browserProcessMatchesExpectedProfile,
61
100
  extractUserDataDirArg,
@@ -68,14 +107,57 @@ export { PLAYWRIGHT_PORTS_DIR };
68
107
 
69
108
  /** Multiple topics assigned to one profile reuse one browser process. */
70
109
  export function makeInstanceKey(userId: string, topic: string | undefined): string {
71
- if (!topic) return makeBrowserProfileInstanceKey(userId, "default");
110
+ return resolvePlaywrightTopicBinding(userId, topic).instanceKey;
111
+ }
112
+
113
+ export function resolvePlaywrightTopicBinding(
114
+ userId: string,
115
+ topic: string | undefined,
116
+ ): PlaywrightProfileBinding {
117
+ return validateProfileBinding(managerHost.resolveTopicBinding(userId, topic));
118
+ }
119
+
120
+ function defaultTopicBinding(userId: string, topic: string | undefined): PlaywrightProfileBinding {
121
+ if (!topic) return managerHost.resolveNamedBinding(userId, "default");
72
122
  const ownerId = getBrowserProfileOwner(topic, userId);
73
123
  const profile = migrateLegacyTopicProfile(ownerId, topic);
74
- return makeBrowserProfileInstanceKey(ownerId, profile);
124
+ return managerHost.resolveNamedBinding(ownerId, profile);
75
125
  }
76
126
 
77
127
  export function makeBrowserProfileInstanceKey(ownerId: string, rawProfile: string): string {
78
- return `profile:${encodeURIComponent(ownerId)}:${normalizeBrowserProfileName(rawProfile)}`;
128
+ return resolvePlaywrightProfileBinding(ownerId, rawProfile).instanceKey;
129
+ }
130
+
131
+ export function resolvePlaywrightProfileBinding(
132
+ ownerId: string,
133
+ rawProfile: string,
134
+ ): PlaywrightProfileBinding {
135
+ return validateProfileBinding(managerHost.resolveNamedBinding(ownerId, rawProfile));
136
+ }
137
+
138
+ function validateProfileBinding(binding: PlaywrightProfileBinding): PlaywrightProfileBinding {
139
+ if (
140
+ !binding ||
141
+ typeof binding.instanceKey !== "string" ||
142
+ !binding.instanceKey.trim() ||
143
+ typeof binding.ownerId !== "string" ||
144
+ !binding.ownerId.trim() ||
145
+ typeof binding.profile !== "string" ||
146
+ !binding.profile.trim()
147
+ ) {
148
+ throw new Error("invalid Playwright profile binding");
149
+ }
150
+ return Object.freeze({ ...binding });
151
+ }
152
+
153
+ function defaultNamedBinding(ownerId: string, rawProfile: string): PlaywrightProfileBinding {
154
+ const profile = normalizeBrowserProfileName(rawProfile);
155
+ const instanceKey = `profile:${encodeURIComponent(ownerId)}:${profile}`;
156
+ return {
157
+ instanceKey,
158
+ ownerId,
159
+ profile,
160
+ };
79
161
  }
80
162
 
81
163
  export function legacyBrowserProfileName(topic: string): string {
@@ -90,7 +172,7 @@ function migrateLegacyTopicProfile(ownerId: string, topic: string): string {
90
172
  if (!existsSync(legacyDir)) return current;
91
173
 
92
174
  const profile = legacyBrowserProfileName(topic);
93
- const profileDir = resolveProfileDir(ownerId, profile);
175
+ const profileDir = defaultProfileDir(ownerId, profile);
94
176
  mkdirSync(dirname(profileDir), { recursive: true });
95
177
  if (!existsSync(profileDir)) renameSync(legacyDir, profileDir);
96
178
  assignTopicBrowserProfile({ topicId: topic, actorUserId: ownerId, profile });
@@ -98,6 +180,88 @@ function migrateLegacyTopicProfile(ownerId: string, topic: string): string {
98
180
  return profile;
99
181
  }
100
182
 
183
+ function defaultChildEnvironment(context: PlaywrightChildEnvironmentContext): NodeJS.ProcessEnv {
184
+ const { capability, ownerId, proxy, browserRsBin, environment } = context;
185
+ return {
186
+ ...environment,
187
+ NEGOTIUM_BROWSER_CAPABILITY: capability,
188
+ NEGOTIUM_BROWSER_VAULT_USER_ID: ownerId,
189
+ ...(browserRsBin && !proxy ? { NEGOTIUM_BROWSER_RS_BIN: browserRsBin } : {}),
190
+ ...(proxy
191
+ ? {
192
+ NEGOTIUM_BROWSER_PROXY_SERVER: proxy.server,
193
+ ...(proxy.username ? { NEGOTIUM_BROWSER_PROXY_USERNAME: proxy.username } : {}),
194
+ ...(proxy.password ? { NEGOTIUM_BROWSER_PROXY_PASSWORD: proxy.password } : {}),
195
+ ...(proxy.bypass ? { NEGOTIUM_BROWSER_PROXY_BYPASS: proxy.bypass } : {}),
196
+ }
197
+ : {}),
198
+ };
199
+ }
200
+
201
+ const defaultManagerHost: Readonly<PlaywrightManagerHost> = Object.freeze({
202
+ portsDir: PLAYWRIGHT_PORTS_DIR,
203
+ basePort: PLAYWRIGHT_BASE_PORT,
204
+ maxPort: PLAYWRIGHT_MAX_PORT,
205
+ browserBin: PLAYWRIGHT_MCP_BIN,
206
+ fallbackBrowserBin: PATCHRIGHT_MCP_BIN,
207
+ browserRsBin: BROWSER_RS_BIN,
208
+ resolveProxy: resolveBrowserProxy,
209
+ resolveTopicBinding: defaultTopicBinding,
210
+ resolveNamedBinding: defaultNamedBinding,
211
+ resolveInstanceDataDir(instanceKey: string) {
212
+ const { ownerId, profile } = parseInstanceKey(instanceKey);
213
+ return defaultProfileDir(ownerId, profile);
214
+ },
215
+ createChildEnvironment: defaultChildEnvironment,
216
+ cleanupBrowserProcessesForDataDir: killBrowserProcsForUserDataDir,
217
+ reapOrphanBrowsers,
218
+ });
219
+
220
+ let managerHost: Readonly<PlaywrightManagerHost> = defaultManagerHost;
221
+
222
+ /**
223
+ * Configure product-specific profile storage and browser launch glue.
224
+ * Call once during bootstrap, before any browser instance is started.
225
+ */
226
+ export function configurePlaywrightManagerHost(
227
+ overrides: Partial<PlaywrightManagerHost>,
228
+ ): Readonly<PlaywrightManagerHost> {
229
+ if (instances.size > 0 || spawning.size > 0 || pinnedInstances.size > 0) {
230
+ throw new Error("cannot configure Playwright manager while browser instances are active");
231
+ }
232
+ const next = { ...managerHost, ...overrides };
233
+ if (!next.portsDir || !Number.isInteger(next.basePort) || !Number.isInteger(next.maxPort)) {
234
+ throw new Error("invalid Playwright manager paths or port range");
235
+ }
236
+ if (next.basePort < 1 || next.maxPort > 65_535 || next.basePort > next.maxPort) {
237
+ throw new Error("invalid Playwright manager port range");
238
+ }
239
+ if (!next.browserBin || !next.fallbackBrowserBin) {
240
+ throw new Error("Playwright manager browser binaries are required");
241
+ }
242
+ if (
243
+ next.resolveInstanceDataDir !== defaultManagerHost.resolveInstanceDataDir &&
244
+ (next.cleanupBrowserProcessesForDataDir ===
245
+ defaultManagerHost.cleanupBrowserProcessesForDataDir ||
246
+ next.reapOrphanBrowsers === defaultManagerHost.reapOrphanBrowsers)
247
+ ) {
248
+ throw new Error(
249
+ "custom Playwright profile paths require host crash cleanup and orphan sweep hooks",
250
+ );
251
+ }
252
+ managerHost = Object.freeze(next);
253
+ return managerHost;
254
+ }
255
+
256
+ export function getPlaywrightManagerHost(): Readonly<PlaywrightManagerHost> {
257
+ return managerHost;
258
+ }
259
+
260
+ /** Restore Negotium's built-in browser host after all instances are stopped. */
261
+ export function resetPlaywrightManagerHost(): void {
262
+ configurePlaywrightManagerHost(defaultManagerHost);
263
+ }
264
+
101
265
  interface InstanceKeyParts {
102
266
  ownerId: string;
103
267
  profile: string;
@@ -118,8 +282,8 @@ function portFileName(instanceKey: string): string {
118
282
 
119
283
  function writePortFile(instanceKey: string, port: number) {
120
284
  try {
121
- mkdirSync(PLAYWRIGHT_PORTS_DIR, { recursive: true });
122
- writeFileSync(join(PLAYWRIGHT_PORTS_DIR, portFileName(instanceKey)), String(port));
285
+ mkdirSync(managerHost.portsDir, { recursive: true });
286
+ writeFileSync(join(managerHost.portsDir, portFileName(instanceKey)), String(port));
123
287
  } catch (e) {
124
288
  logger.warn({ err: e, instanceKey, port }, "Failed to save playwright port file");
125
289
  }
@@ -127,7 +291,7 @@ function writePortFile(instanceKey: string, port: number) {
127
291
 
128
292
  function deletePortFile(instanceKey: string) {
129
293
  try {
130
- unlinkSync(join(PLAYWRIGHT_PORTS_DIR, portFileName(instanceKey)));
294
+ unlinkSync(join(managerHost.portsDir, portFileName(instanceKey)));
131
295
  } catch (e) {
132
296
  logger.warn({ err: e, instanceKey }, "Failed to delete playwright port file");
133
297
  }
@@ -136,7 +300,7 @@ function deletePortFile(instanceKey: string) {
136
300
  function readPortFile(instanceKey: string): number | null {
137
301
  try {
138
302
  const port = Number.parseInt(
139
- readFileSync(join(PLAYWRIGHT_PORTS_DIR, portFileName(instanceKey)), "utf8").trim(),
303
+ readFileSync(join(managerHost.portsDir, portFileName(instanceKey)), "utf8").trim(),
140
304
  10,
141
305
  );
142
306
  return Number.isInteger(port) ? port : null;
@@ -145,14 +309,12 @@ function readPortFile(instanceKey: string): number | null {
145
309
  }
146
310
  }
147
311
 
148
- const BASE_PORT = PLAYWRIGHT_BASE_PORT;
149
- const MAX_PORT = PLAYWRIGHT_MAX_PORT;
150
312
  const MAX_IDLE_MS = 2 * 60 * 60 * 1000; // 2 hours idle → eligible for eviction
151
313
 
152
314
  interface PlaywrightInstance {
153
315
  process: ChildProcess;
154
316
  port: number;
155
- userId: string;
317
+ ownerId: string;
156
318
  startedAt: number;
157
319
  lastUsedAt: number;
158
320
  capability: string;
@@ -160,18 +322,33 @@ interface PlaywrightInstance {
160
322
 
161
323
  const instances = new Map<string, PlaywrightInstance>();
162
324
 
325
+ function assertInstanceOwner(instanceKey: string, ownerId: string): void {
326
+ const existing = instances.get(instanceKey);
327
+ if (existing && existing.ownerId !== ownerId) {
328
+ throw new Error(
329
+ `Playwright profile binding owner changed for "${instanceKey}" (${existing.ownerId} -> ${ownerId})`,
330
+ );
331
+ }
332
+ }
333
+
163
334
  // Track used ports to avoid collisions
164
335
  const usedPorts = new Set<number>();
165
336
 
166
337
  // Prevent concurrent spawns for the same key
167
338
  const spawning = new Map<string, Promise<number | null>>();
168
339
 
340
+ export interface PlaywrightMaintenanceControl {
341
+ /** Stop one of the locked instances before mutating its profile directory. */
342
+ stopInstance(instanceKey: string): Promise<boolean>;
343
+ }
344
+
169
345
  /** Serialize destructive profile maintenance with normal browser startup. */
170
346
  export async function withPlaywrightInstanceMaintenance<T>(
171
347
  rawKeys: string[],
172
- operation: () => Promise<T>,
348
+ operation: (control: PlaywrightMaintenanceControl) => Promise<T>,
173
349
  ): Promise<T> {
174
350
  const keys = [...new Set(rawKeys)].sort();
351
+ const lockedKeys = new Set(keys);
175
352
  for (;;) {
176
353
  const pending = keys
177
354
  .map((key) => spawning.get(key))
@@ -190,7 +367,32 @@ export async function withPlaywrightInstanceMaintenance<T>(
190
367
  for (const key of keys) spawning.set(key, barrier);
191
368
 
192
369
  try {
193
- return await operation();
370
+ return await operation({
371
+ async stopInstance(instanceKey) {
372
+ if (!lockedKeys.has(instanceKey)) {
373
+ throw new Error(`Playwright maintenance does not own instance "${instanceKey}"`);
374
+ }
375
+ const process = instances.get(instanceKey)?.process;
376
+ if (!process) return false;
377
+ killInstance(instanceKey);
378
+ if (!(await waitForChildProcessExit(process, 3000))) {
379
+ logger.warn(
380
+ { instanceKey, pid: process.pid },
381
+ "Playwright MCP ignored SIGTERM during profile maintenance",
382
+ );
383
+ try {
384
+ process.kill("SIGKILL");
385
+ } catch (error) {
386
+ logger.warn(
387
+ { err: error, instanceKey, pid: process.pid },
388
+ "Failed to SIGKILL Playwright MCP during profile maintenance",
389
+ );
390
+ }
391
+ await waitForChildProcessExit(process, 1000);
392
+ }
393
+ return true;
394
+ },
395
+ });
194
396
  } finally {
195
397
  for (const key of keys) {
196
398
  if (spawning.get(key) === barrier) spawning.delete(key);
@@ -200,6 +402,36 @@ export async function withPlaywrightInstanceMaintenance<T>(
200
402
  }
201
403
  }
202
404
 
405
+ /** Stop one managed instance under the same serialization used by startup and profile mutation. */
406
+ export function stopPlaywrightInstance(instanceKey: string): Promise<boolean> {
407
+ return withPlaywrightInstanceMaintenance([instanceKey], ({ stopInstance }) =>
408
+ stopInstance(instanceKey),
409
+ );
410
+ }
411
+
412
+ /** Run owner-aware named-profile maintenance under one lifecycle barrier. */
413
+ export function withPlaywrightProfileMaintenance<T>(
414
+ ownerId: string,
415
+ rawProfile: string,
416
+ operation: (
417
+ binding: PlaywrightProfileBinding,
418
+ control: PlaywrightMaintenanceControl,
419
+ ) => Promise<T>,
420
+ ): Promise<T> {
421
+ const binding = resolvePlaywrightProfileBinding(ownerId, rawProfile);
422
+ return withPlaywrightInstanceMaintenance([binding.instanceKey], async (control) => {
423
+ assertInstanceOwner(binding.instanceKey, binding.ownerId);
424
+ return operation(binding, control);
425
+ });
426
+ }
427
+
428
+ /** Stop a named profile before deleting or replacing its host-managed data directory. */
429
+ export function stopPlaywrightProfile(ownerId: string, rawProfile: string): Promise<boolean> {
430
+ return withPlaywrightProfileMaintenance(ownerId, rawProfile, (binding, { stopInstance }) =>
431
+ stopInstance(binding.instanceKey),
432
+ );
433
+ }
434
+
203
435
  // A shared profile may have several concurrent turns. Reference counts keep an
204
436
  // idle sweep from evicting the process until every borrower has finished.
205
437
  const pinnedInstances = new Map<string, number>();
@@ -219,16 +451,48 @@ export function getPlaywrightCapability(instanceKey: string): string | undefined
219
451
  return instances.get(instanceKey)?.capability;
220
452
  }
221
453
 
222
- // --- Abnormal exit notification callback ---
223
- // Fired when a Playwright MCP child process exits with a non-zero / non-null
224
- // code that did NOT originate from our own killInstance() (those paths are
225
- // intentional cleanups, not crashes). Consumers use this to abort any
226
- // in-flight `handleAgentQuery` whose Claude SDK is waiting on a tool result
227
- // the dead MCP can never deliver — preventing 20+ minute typing-only stalls.
228
- type PlaywrightExitHandler = (instanceKey: string, code: number | null) => void;
229
- let _onPlaywrightExit: PlaywrightExitHandler | null = null;
230
- export function onPlaywrightExit(handler: PlaywrightExitHandler) {
231
- _onPlaywrightExit = handler;
454
+ /** Resolve a live browser capability back to its owning user. */
455
+ export function resolvePlaywrightCapabilityOwner(capability: string): string | undefined {
456
+ if (!capability) return undefined;
457
+ const provided = Buffer.from(capability);
458
+ for (const instance of instances.values()) {
459
+ const expected = Buffer.from(instance.capability);
460
+ if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
461
+ return instance.ownerId;
462
+ }
463
+ }
464
+ return undefined;
465
+ }
466
+
467
+ /** Run the configured host's path-bounded orphan sweep against current live instances. */
468
+ export function reapPlaywrightOrphans(): void {
469
+ managerHost.reapOrphanBrowsers([...instances.keys()].map(resolveUserDataDir));
470
+ }
471
+
472
+ // --- Browser MCP failure notification callback ---
473
+ // Fired when a managed process fails or its transport becomes unhealthy.
474
+ // Intentional cleanup paths do not notify consumers.
475
+ export interface PlaywrightFailure {
476
+ reason: "exit" | "process-error" | "unhealthy";
477
+ code?: number | null;
478
+ error?: string;
479
+ }
480
+
481
+ type PlaywrightFailureHandler = (instanceKey: string, failure: PlaywrightFailure) => void;
482
+ const playwrightFailureHandlers = new Set<PlaywrightFailureHandler>();
483
+ export function onPlaywrightFailure(handler: PlaywrightFailureHandler): () => void {
484
+ playwrightFailureHandlers.add(handler);
485
+ return () => playwrightFailureHandlers.delete(handler);
486
+ }
487
+
488
+ function notifyPlaywrightFailure(instanceKey: string, failure: PlaywrightFailure): void {
489
+ for (const handler of playwrightFailureHandlers) {
490
+ try {
491
+ handler(instanceKey, failure);
492
+ } catch (err) {
493
+ logger.warn({ err, instanceKey, failure }, "Playwright failure handler failed");
494
+ }
495
+ }
232
496
  }
233
497
 
234
498
  /**
@@ -265,7 +529,7 @@ function evictIdleInstance(): number | null {
265
529
  * mistake a live topic Playwright for an orphan and SIGKILL it.
266
530
  */
267
531
  async function allocatePort(expectedUserDataDir?: string): Promise<number> {
268
- for (let port = BASE_PORT; port <= MAX_PORT; port++) {
532
+ for (let port = managerHost.basePort; port <= managerHost.maxPort; port++) {
269
533
  if (usedPorts.has(port)) continue;
270
534
  usedPorts.add(port); // Reserve immediately before any await to prevent concurrent allocation
271
535
 
@@ -289,7 +553,12 @@ async function allocatePort(expectedUserDataDir?: string): Promise<number> {
289
553
  // until the old listener is actually gone, and recheck every candidate in
290
554
  // case another allocator claimed a different port while we were waiting.
291
555
  await waitForPortRelease(evictedPort);
292
- const reusablePort = selectReusablePort(BASE_PORT, MAX_PORT, usedPorts, isPortInUse);
556
+ const reusablePort = selectReusablePort(
557
+ managerHost.basePort,
558
+ managerHost.maxPort,
559
+ usedPorts,
560
+ isPortInUse,
561
+ );
293
562
  if (reusablePort !== null) {
294
563
  usedPorts.add(reusablePort);
295
564
  return reusablePort;
@@ -297,7 +566,7 @@ async function allocatePort(expectedUserDataDir?: string): Promise<number> {
297
566
  }
298
567
 
299
568
  throw new Error(
300
- `No available ports for Playwright MCP (${instances.size} active instances, range ${BASE_PORT}-${MAX_PORT})`,
569
+ `No available ports for Playwright MCP (${instances.size} active instances, range ${managerHost.basePort}-${managerHost.maxPort})`,
301
570
  );
302
571
  }
303
572
 
@@ -310,14 +579,13 @@ function ownerDirectory(ownerId: string): string {
310
579
  return `${sanitizeTopicName(ownerId).slice(0, 24)}_${digest}`;
311
580
  }
312
581
 
313
- function resolveProfileDir(ownerId: string, profile: string): string {
582
+ function defaultProfileDir(ownerId: string, profile: string): string {
314
583
  return resolve(BROWSER_PROFILES_DIR, "profiles", ownerDirectory(ownerId), profile);
315
584
  }
316
585
 
317
586
  /** Resolve the shared profile userDataDir for an instanceKey. */
318
587
  function resolveUserDataDir(instanceKey: string): string {
319
- const { ownerId, profile } = parseInstanceKey(instanceKey);
320
- return resolveProfileDir(ownerId, profile);
588
+ return managerHost.resolveInstanceDataDir(instanceKey);
321
589
  }
322
590
 
323
591
  /**
@@ -366,15 +634,15 @@ function killInstance(instanceKey: string, opts?: { keepPort?: boolean }) {
366
634
  /**
367
635
  * Spawn a Playwright MCP process for the given instanceKey.
368
636
  * @param instanceKey Unique key identifying this instance.
369
- * @param userId Owner user ID (stored in the instance record).
637
+ * @param ownerId Canonical profile owner ID (stored in the instance record).
370
638
  * @param reservedPort When provided the process binds to this already-reserved
371
639
  * port (restart path). When omitted a fresh port is allocated.
372
640
  */
373
641
  async function spawnPlaywright(
374
642
  instanceKey: string,
375
- userId: string,
643
+ ownerId: string,
376
644
  reservedPort?: number,
377
- browserBin = PLAYWRIGHT_MCP_BIN,
645
+ browserBin = managerHost.browserBin,
378
646
  allowFallback = true,
379
647
  ): Promise<number> {
380
648
  const userDataDir = resolveUserDataDir(instanceKey);
@@ -412,22 +680,22 @@ async function spawnPlaywright(
412
680
  // argv so the credentials never surface in `ps`/`/proc` command lines. The
413
681
  // launcher (scripts/mcp-patchright-http.mjs) reads these NEGOTIUM_BROWSER_PROXY_*
414
682
  // vars and hands them to Playwright's per-context proxy option.
415
- const proxy = resolveBrowserProxy();
683
+ const proxy = managerHost.resolveProxy();
416
684
  const capability = randomBytes(32).toString("hex");
417
685
  const childEnv = {
418
- ...process.env,
686
+ ...managerHost.createChildEnvironment({
687
+ instanceKey,
688
+ ownerId,
689
+ capability,
690
+ proxy,
691
+ browserRsBin: managerHost.browserRsBin,
692
+ environment: process.env,
693
+ }),
419
694
  NEGOTIUM_BROWSER_CAPABILITY: capability,
420
- NEGOTIUM_BROWSER_VAULT_USER_ID: userId,
421
- ...(BROWSER_RS_BIN && !proxy ? { NEGOTIUM_BROWSER_RS_BIN: BROWSER_RS_BIN } : {}),
422
- ...(proxy
423
- ? {
424
- NEGOTIUM_BROWSER_PROXY_SERVER: proxy.server,
425
- ...(proxy.username ? { NEGOTIUM_BROWSER_PROXY_USERNAME: proxy.username } : {}),
426
- ...(proxy.password ? { NEGOTIUM_BROWSER_PROXY_PASSWORD: proxy.password } : {}),
427
- ...(proxy.bypass ? { NEGOTIUM_BROWSER_PROXY_BYPASS: proxy.bypass } : {}),
428
- }
429
- : {}),
430
695
  };
696
+ // Every supported wrapper authenticates owner-scoped transports with this
697
+ // capability. Product-specific environment hooks may add Vault callbacks,
698
+ // but cannot remove transport authentication.
431
699
  if (proxy) {
432
700
  logger.info({ instanceKey, proxyServer: proxy.server }, "Browser egress proxy enabled");
433
701
  }
@@ -461,7 +729,7 @@ async function spawnPlaywright(
461
729
  // removes these listeners before signalling, so this only runs on real deaths.
462
730
  const reapCrashedBrowser = () => {
463
731
  const userDataDir = resolveUserDataDir(instanceKey);
464
- killBrowserProcsForUserDataDir(userDataDir);
732
+ managerHost.cleanupBrowserProcessesForDataDir(userDataDir);
465
733
  cleanSingletonFiles(userDataDir);
466
734
  };
467
735
 
@@ -471,6 +739,10 @@ async function spawnPlaywright(
471
739
  releasePort(port);
472
740
  instances.delete(instanceKey);
473
741
  reapCrashedBrowser();
742
+ notifyPlaywrightFailure(instanceKey, {
743
+ reason: "process-error",
744
+ error: err instanceof Error ? err.message : String(err),
745
+ });
474
746
  }
475
747
  });
476
748
 
@@ -486,7 +758,7 @@ async function spawnPlaywright(
486
758
  releasePort(port);
487
759
  instances.delete(instanceKey);
488
760
  reapCrashedBrowser();
489
- _onPlaywrightExit?.(instanceKey, code);
761
+ notifyPlaywrightFailure(instanceKey, { reason: "exit", code });
490
762
  }
491
763
  });
492
764
 
@@ -494,7 +766,7 @@ async function spawnPlaywright(
494
766
  instances.set(instanceKey, {
495
767
  process: proc,
496
768
  port,
497
- userId,
769
+ ownerId,
498
770
  startedAt: now,
499
771
  lastUsedAt: now,
500
772
  capability,
@@ -502,18 +774,26 @@ async function spawnPlaywright(
502
774
 
503
775
  const ready = await Promise.race([
504
776
  (async () =>
505
- (await waitForServer(port, 10_000)) && (await supportsOwnerCleanup(port, capability)))(),
777
+ (await waitForServer(port, 10_000)) &&
778
+ (await supportsOwnerCleanup(port, capability)) &&
779
+ (await probePlaywrightMcpTransports(port, capability)))(),
506
780
  spawnError,
507
781
  ]);
508
782
  if (!ready) {
509
783
  const exitCode = proc.exitCode;
510
784
  killInstance(instanceKey);
511
- if (allowFallback && browserBin !== PATCHRIGHT_MCP_BIN) {
785
+ if (allowFallback && browserBin !== managerHost.fallbackBrowserBin) {
512
786
  logger.warn(
513
- { instanceKey, browserBin, fallback: PATCHRIGHT_MCP_BIN },
787
+ { instanceKey, browserBin, fallback: managerHost.fallbackBrowserBin },
514
788
  "Preferred browser MCP unavailable or lacks owner isolation; using Patchright fallback",
515
789
  );
516
- return spawnPlaywright(instanceKey, userId, undefined, PATCHRIGHT_MCP_BIN, false);
790
+ return spawnPlaywright(
791
+ instanceKey,
792
+ ownerId,
793
+ undefined,
794
+ managerHost.fallbackBrowserBin,
795
+ false,
796
+ );
517
797
  }
518
798
  throw new Error(
519
799
  `Playwright MCP failed health check after spawn on port ${port}` +
@@ -553,12 +833,14 @@ async function supportsOwnerCleanup(port: number, capability: string): Promise<b
553
833
  * - If not running → spawn
554
834
  */
555
835
  export async function ensurePlaywright(userId: string, topic?: string): Promise<number> {
556
- const instanceKey = makeInstanceKey(userId, topic);
836
+ const binding = resolvePlaywrightTopicBinding(userId, topic);
837
+ const { instanceKey, ownerId } = binding;
557
838
 
558
839
  // If a spawn/restart is already in progress for this key, wait for it
559
840
  const inProgress = spawning.get(instanceKey);
560
841
  if (inProgress) {
561
842
  const port = await inProgress;
843
+ assertInstanceOwner(instanceKey, ownerId);
562
844
  if (port !== null) return port;
563
845
  // Restart failed — re-enter: by now another caller may have registered a
564
846
  // newer attempt to join, otherwise we start one ourselves.
@@ -575,24 +857,30 @@ export async function ensurePlaywright(userId: string, topic?: string): Promise<
575
857
  // a single-threaded event loop.
576
858
  const promise = (async (): Promise<number> => {
577
859
  const existing = instances.get(instanceKey);
860
+ assertInstanceOwner(instanceKey, ownerId);
578
861
 
579
862
  if (existing && !existing.process.killed && existing.process.exitCode === null) {
580
- if (await isHealthy(existing.port)) {
863
+ if (await probePlaywrightMcpTransports(existing.port, existing.capability)) {
581
864
  existing.lastUsedAt = Date.now();
582
865
  return existing.port;
583
866
  }
584
867
  logger.warn({ instanceKey }, "Playwright MCP unresponsive, restarting");
585
868
  const oldPort = existing.port;
586
869
  killInstance(instanceKey);
870
+ notifyPlaywrightFailure(instanceKey, { reason: "unhealthy" });
587
871
  await waitForPortRelease(oldPort);
588
872
  cleanSingletonFiles(resolveUserDataDir(instanceKey));
589
873
  } else if (existing) {
590
874
  releasePort(existing.port);
591
875
  instances.delete(instanceKey);
592
876
  cleanSingletonFiles(resolveUserDataDir(instanceKey));
877
+ notifyPlaywrightFailure(instanceKey, {
878
+ reason: "exit",
879
+ code: existing.process.exitCode,
880
+ });
593
881
  }
594
882
 
595
- return spawnPlaywright(instanceKey, userId);
883
+ return spawnPlaywright(instanceKey, ownerId);
596
884
  })().finally(() => spawning.delete(instanceKey));
597
885
  spawning.set(instanceKey, promise);
598
886
  return promise;
@@ -608,11 +896,16 @@ async function waitForPortRelease(port: number, timeoutMs = 3000): Promise<void>
608
896
 
609
897
  /** Start or reuse a named shared profile before assigning more topics to it. */
610
898
  export async function ensureBrowserProfile(ownerId: string, rawProfile: string): Promise<number> {
611
- const instanceKey = makeBrowserProfileInstanceKey(ownerId, rawProfile);
899
+ const binding = resolvePlaywrightProfileBinding(ownerId, rawProfile);
900
+ const instanceKey = binding.instanceKey;
612
901
  const inProgress = spawning.get(instanceKey);
613
902
  if (inProgress) {
614
903
  const port = await inProgress;
615
- if (port !== null && (await isHealthy(port))) return port;
904
+ assertInstanceOwner(instanceKey, binding.ownerId);
905
+ const capability = instances.get(instanceKey)?.capability;
906
+ if (port !== null && capability && (await probePlaywrightMcpTransports(port, capability))) {
907
+ return port;
908
+ }
616
909
  if (port === null) return ensureBrowserProfile(ownerId, rawProfile);
617
910
  throw new Error(`Browser profile "${rawProfile}" failed to start.`);
618
911
  }
@@ -620,6 +913,7 @@ export async function ensureBrowserProfile(ownerId: string, rawProfile: string):
620
913
  const promise = (async (): Promise<number> => {
621
914
  const publishedPort = readPortFile(instanceKey);
622
915
  const existing = instances.get(instanceKey);
916
+ assertInstanceOwner(instanceKey, binding.ownerId);
623
917
  if (publishedPort !== null && !existing) {
624
918
  // A port file without an in-memory instance belongs to a previous runtime.
625
919
  // Its per-process capability and ChildProcess handle cannot be recovered,
@@ -635,17 +929,27 @@ export async function ensureBrowserProfile(ownerId: string, rawProfile: string):
635
929
  }
636
930
 
637
931
  if (existing && !existing.process.killed && existing.process.exitCode === null) {
638
- if (await isHealthy(existing.port)) {
932
+ if (await probePlaywrightMcpTransports(existing.port, existing.capability)) {
639
933
  existing.lastUsedAt = Date.now();
640
934
  return existing.port;
641
935
  }
642
936
  const oldPort = existing.port;
643
937
  killInstance(instanceKey);
938
+ notifyPlaywrightFailure(instanceKey, { reason: "unhealthy" });
644
939
  await waitForPortRelease(oldPort);
645
940
  cleanSingletonFiles(resolveUserDataDir(instanceKey));
941
+ } else if (existing) {
942
+ releasePort(existing.port);
943
+ instances.delete(instanceKey);
944
+ cleanSingletonFiles(resolveUserDataDir(instanceKey));
945
+ notifyPlaywrightFailure(instanceKey, {
946
+ reason: "exit",
947
+ code: existing.process.exitCode,
948
+ });
646
949
  }
647
- const port = await spawnPlaywright(instanceKey, ownerId);
648
- if (!(await isHealthy(port))) {
950
+ const port = await spawnPlaywright(instanceKey, binding.ownerId);
951
+ const capability = instances.get(instanceKey)?.capability;
952
+ if (!capability || !(await probePlaywrightMcpTransports(port, capability))) {
649
953
  killInstance(instanceKey);
650
954
  throw new Error(`Browser profile "${rawProfile}" did not pass its health check.`);
651
955
  }
@@ -661,9 +965,11 @@ export async function closeBrowserOwnerTabs(
661
965
  rawProfile: string,
662
966
  owner: string,
663
967
  ): Promise<number> {
664
- const instanceKey = makeBrowserProfileInstanceKey(ownerId, rawProfile);
968
+ const binding = resolvePlaywrightProfileBinding(ownerId, rawProfile);
969
+ const instanceKey = binding.instanceKey;
665
970
  const inProgress = spawning.get(instanceKey);
666
971
  if (inProgress) await inProgress;
972
+ assertInstanceOwner(instanceKey, binding.ownerId);
667
973
  const instance = instances.get(instanceKey);
668
974
  if (!instance) return 0;
669
975
  const port = instance.port;
@@ -751,18 +1057,26 @@ export async function cloneProfileForChild(opts: {
751
1057
  const srcOwner = getBrowserProfileOwner(opts.srcTopic, opts.userId);
752
1058
  const dstOwner = getBrowserProfileOwner(opts.dstTopic, opts.userId);
753
1059
  if (srcOwner !== dstOwner) {
754
- const dstDir = resolveProfileDir(dstOwner, getTopicBrowserProfile(opts.dstTopic));
1060
+ const srcBinding = resolvePlaywrightProfileBinding(
1061
+ srcOwner,
1062
+ getTopicBrowserProfile(opts.srcTopic),
1063
+ );
1064
+ const dstBinding = resolvePlaywrightProfileBinding(
1065
+ dstOwner,
1066
+ getTopicBrowserProfile(opts.dstTopic),
1067
+ );
755
1068
  return {
756
1069
  copied: false,
757
- srcDir: resolveProfileDir(srcOwner, getTopicBrowserProfile(opts.srcTopic)),
758
- dstDir,
1070
+ srcDir: resolveUserDataDir(srcBinding.instanceKey),
1071
+ dstDir: resolveUserDataDir(dstBinding.instanceKey),
759
1072
  reason: "cross-owner-fresh-profile",
760
1073
  };
761
1074
  }
762
1075
  if (srcOwner === dstOwner && hasBrowserProfileTopic(opts.dstTopic)) {
763
1076
  const profile = getTopicBrowserProfile(opts.srcTopic);
764
1077
  assignTopicBrowserProfile({ topicId: opts.dstTopic, actorUserId: dstOwner, profile });
765
- const sharedDir = resolveProfileDir(srcOwner, profile);
1078
+ const binding = resolvePlaywrightProfileBinding(srcOwner, profile);
1079
+ const sharedDir = resolveUserDataDir(binding.instanceKey);
766
1080
  return {
767
1081
  copied: false,
768
1082
  srcDir: sharedDir,
@@ -891,7 +1205,7 @@ setInterval(
891
1205
  () => {
892
1206
  while (evictIdleInstance() !== null) {}
893
1207
  try {
894
- reapOrphanBrowsers([...instances.keys()].map(resolveUserDataDir));
1208
+ reapPlaywrightOrphans();
895
1209
  } catch (e) {
896
1210
  logger.debug({ err: e }, "reapOrphanBrowsers sweep failed");
897
1211
  }