openmausbot 0.1.58 → 0.1.59

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.
@@ -84,8 +84,8 @@ import { redactSecretsInText } from "./redact.js";
84
84
  import * as vps from "./vps-computer.js";
85
85
  import { RoutineManager } from "./routines.js";
86
86
  import { CalendarCallManager } from "./calendar-calls.js";
87
- import { BUILT_IN_BROWSER_SYSTEM_PROMPT, applyDesktopBrowserConnectionMessage, availableBrowserConnection, browserScreenshot, clearBrowserCapabilities, registerBrowserCapability, revokeBrowserCapability, } from "./browser-connection.js";
88
- import { agentBrowserIntegration, browserEngineEncryptionKey, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
87
+ import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
88
+ import { agentBrowserIntegration, browserEngineEncryptionKey, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
89
89
  import { captureOutsideHumanControl } from "./private-screen-capture.js";
90
90
  import { screenFrameHash, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
91
91
  import { RoutineRequestService } from "./routine-requests.js";
@@ -203,9 +203,40 @@ function applyDesktopMutationTokenMessage(raw) {
203
203
  }
204
204
  return true;
205
205
  }
206
+ // Browser data of a deleted bot or profile: the engine's saved session
207
+ // state, cleared here on every host (the desktop no longer owns a browser).
208
+ // The coordinator keeps its durable journal and replay; this is its outbox.
206
209
  const browserCleanup = new BrowserCleanupCoordinator({
207
210
  file: join(DATA_DIR, "browser-cleanups.json"),
208
- send: postDesktopPrivateMessage,
211
+ send: (request) => {
212
+ const status = browserEngineStatus();
213
+ // Guest sessions are throwaway and never saved, so only the bot's own
214
+ // session and shared profile sessions have state to clear.
215
+ const sessions = request.type === "openmausbot:browser-bot-deleted" && request.botId
216
+ ? [browserSessionId(request.botId, "")]
217
+ : request.partitionId
218
+ ? [browserSessionId("", request.partitionId)]
219
+ : [];
220
+ // Failed erasure leaves the committed intent pending for retry; it must
221
+ // never acknowledge that saved state was removed when it was not.
222
+ const work = status.kind === "ready" && sessions.length
223
+ ? Promise.all(sessions.map(async (session) => {
224
+ const ok = await clearBrowserSessionState(status.binaryPath, session, { encryptionKey: browserEngineEncryptionKey() });
225
+ if (!ok)
226
+ console.warn(`browser cleanup: could not clear the engine's saved state for session ${session}; clear it with \`agent-browser --session ${session} state clear --all\``);
227
+ return ok;
228
+ }))
229
+ : Promise.resolve([true]);
230
+ void work.then((results) => results.every(Boolean), (error) => {
231
+ console.warn("browser cleanup: could not clear saved session state", error);
232
+ return false;
233
+ }).then((ok) => {
234
+ browserCleanup.receive({ type: "openmausbot:browser-lifecycle-result", requestId: request.requestId, ok });
235
+ }).catch((error) => {
236
+ console.warn("browser cleanup: could not acknowledge cleanup", error);
237
+ });
238
+ return true;
239
+ },
209
240
  });
210
241
  const phoneSecrets = new PhoneSecretBridge(postDesktopPrivateMessage);
211
242
  utilityParentPort?.on("message", (event) => {
@@ -219,8 +250,7 @@ utilityParentPort?.on("message", (event) => {
219
250
  return;
220
251
  if (phoneSecrets.receive(message))
221
252
  return;
222
- if (!applyDesktopBrowserConnectionMessage(message))
223
- composio.applyManagedBrokerMessage(message);
253
+ composio.applyManagedBrokerMessage(message);
224
254
  }
225
255
  catch (error) {
226
256
  console.error(`[desktop-sync] rejected private parent message: ${error instanceof Error ? error.message : String(error)}`);
@@ -300,10 +330,8 @@ function revokeInternalCapabilityForProviderEvent(event) {
300
330
  * length; only capabilities for currently active turns are retained. */
301
331
  function authorizedInternalCapability(header) {
302
332
  const got = Buffer.from(Array.isArray(header) ? "" : (header ?? ""));
303
- const now = Date.now();
304
333
  for (const [token, capability] of internalCapabilities) {
305
- if (capability.orphanExpiresAt <= now ||
306
- activeInternalGenerationByThread.get(capability.threadId) !== capability.generation) {
334
+ if (!internalCapabilityIsActive(capability)) {
307
335
  internalCapabilities.delete(token);
308
336
  continue;
309
337
  }
@@ -314,6 +342,12 @@ function authorizedInternalCapability(header) {
314
342
  return null;
315
343
  }
316
344
  function internalCapabilityIsActive(capability) {
345
+ if (capability.localVmTarget) {
346
+ const owner = localVmLeaseFor(capability.localVmTarget).current(localVmOwnerBusy);
347
+ if (localVmThreadTargets.get(capability.threadId) !== capability.localVmTarget ||
348
+ owner?.threadId !== capability.threadId || owner.botId !== capability.botId)
349
+ return false;
350
+ }
317
351
  return (capability.orphanExpiresAt > Date.now() &&
318
352
  activeInternalGenerationByThread.get(capability.threadId) === capability.generation);
319
353
  }
@@ -368,84 +402,6 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation) {
368
402
  },
369
403
  };
370
404
  }
371
- const browserCapabilitiesByThread = new Map();
372
- const pendingBrowserCapabilityRevocations = new Map();
373
- const BROWSER_REVOCATION_RETRY_MS = [250, 1_000, 3_000, 10_000, 30_000];
374
- async function revokeReleasedBrowserCapability(active, attempt = 0) {
375
- const token = active.capability.token;
376
- try {
377
- await revokeBrowserCapability(active.connection, active.capability);
378
- const pending = pendingBrowserCapabilityRevocations.get(token);
379
- if (pending)
380
- clearTimeout(pending.timer);
381
- pendingBrowserCapabilityRevocations.delete(token);
382
- }
383
- catch (error) {
384
- if (Date.now() >= active.capability.expiresAt) {
385
- pendingBrowserCapabilityRevocations.delete(token);
386
- return;
387
- }
388
- if (attempt === 0) {
389
- console.error(`[browser] could not revoke turn capability; retrying until its absolute expiry: ${error instanceof Error ? error.message : String(error)}`);
390
- }
391
- const delay = Math.min(BROWSER_REVOCATION_RETRY_MS[Math.min(attempt, BROWSER_REVOCATION_RETRY_MS.length - 1)], Math.max(1, active.capability.expiresAt - Date.now()));
392
- const timer = setTimeout(() => {
393
- const pending = pendingBrowserCapabilityRevocations.get(token);
394
- if (!pending || pending.timer !== timer)
395
- return;
396
- void revokeReleasedBrowserCapability(active, attempt + 1);
397
- }, delay);
398
- timer.unref?.();
399
- const previous = pendingBrowserCapabilityRevocations.get(token);
400
- if (previous)
401
- clearTimeout(previous.timer);
402
- pendingBrowserCapabilityRevocations.set(token, { active, attempt: attempt + 1, timer });
403
- }
404
- }
405
- async function releaseBrowserCapabilityForThread(threadId, expectedOwnerId) {
406
- const active = browserCapabilitiesByThread.get(threadId);
407
- if (!active || (expectedOwnerId !== undefined && active.ownerId !== expectedOwnerId))
408
- return;
409
- browserCapabilitiesByThread.delete(threadId);
410
- await revokeReleasedBrowserCapability(active);
411
- }
412
- async function releaseBrowserCapabilitiesForBot(botId) {
413
- const threads = [...browserCapabilitiesByThread]
414
- .filter(([, active]) => active.botId === botId)
415
- .map(([threadId]) => threadId);
416
- await Promise.all(threads.map((threadId) => releaseBrowserCapabilityForThread(threadId)));
417
- }
418
- async function releaseAllBrowserCapabilities() {
419
- const active = [...browserCapabilitiesByThread.values()];
420
- browserCapabilitiesByThread.clear();
421
- const connections = new Map();
422
- for (const entry of active) {
423
- connections.set(`${entry.connection.url}:${entry.connection.token}`, entry.connection);
424
- }
425
- for (const pending of pendingBrowserCapabilityRevocations.values()) {
426
- connections.set(`${pending.active.connection.url}:${pending.active.connection.token}`, pending.active.connection);
427
- }
428
- await Promise.all([...connections.values()].map(async (connection) => {
429
- try {
430
- // Master clear is atomic at the host. It also invalidates a token whose
431
- // earlier per-turn revoke timed out, which is essential for feature-off
432
- // and graceful-shutdown boundaries.
433
- await clearBrowserCapabilities(connection);
434
- for (const [token, pending] of pendingBrowserCapabilityRevocations) {
435
- if (pending.active.connection.url === connection.url &&
436
- pending.active.connection.token === connection.token) {
437
- clearTimeout(pending.timer);
438
- pendingBrowserCapabilityRevocations.delete(token);
439
- }
440
- }
441
- }
442
- catch {
443
- await Promise.all(active
444
- .filter((entry) => entry.connection.url === connection.url && entry.connection.token === connection.token)
445
- .map((entry) => revokeReleasedBrowserCapability(entry)));
446
- }
447
- }));
448
- }
449
405
  class DirectTurnSetupCancelled extends Error {
450
406
  }
451
407
  const directTurnDispatchClaims = new Map();
@@ -540,55 +496,10 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
540
496
  // start early enough for those old events to settle the replacement.
541
497
  return claim;
542
498
  }
543
- async function browserIntegration(botId, profile, threadId, generation, stillValid = () => true, ownerId = randomUUID()) {
544
- const connection = availableBrowserConnection();
545
- if (!connection)
546
- return engineBrowserIntegration(botId, profile);
547
- const control = controlIntegration(botId, threadId, generation);
548
- // A profile that no longer exists falls back to the bot's own session.
549
- // Canonical ids belong to config/bot references; Electron must receive the
550
- // exact immutable partition inherited from #567 so an upgrade cannot move
551
- // a bot into another account. Guest remains a throwaway partition.
552
- const profileTarget = profile && profile !== "guest"
553
- ? browserProfilePartitionTarget(cfg, profile)
554
- : null;
555
- const partitionId = profile === "guest" ? "guest" : (profileTarget?.partitionId ?? "");
556
- await releaseBrowserCapabilityForThread(threadId);
557
- const capability = await registerBrowserCapability(connection, botId, partitionId);
558
- const active = { botId, ownerId, connection, capability };
559
- // Registration crosses a process boundary. Stop/delete/config changes can
560
- // land while the desktop host is minting the token; revalidate in the same
561
- // event-loop turn that publishes it. If ownership was lost, no agent ever
562
- // receives the bearer and the just-created token is revoked immediately.
563
- if (!stillValid()) {
564
- await revokeReleasedBrowserCapability(active);
565
- return null;
566
- }
567
- browserCapabilitiesByThread.set(threadId, active);
568
- return {
569
- connection,
570
- capability,
571
- profile: partitionId,
572
- integration: {
573
- command: process.execPath,
574
- args: [SPAWNED_PROXIES.browser],
575
- env: {
576
- ...AGENTS_NODE_FLAG,
577
- OMB_BROWSER_URL: connection.url,
578
- OMB_BROWSER_TOKEN: capability.token,
579
- OMB_BROWSER_PROFILE: partitionId,
580
- OMB_BOT_ID: botId,
581
- OMB_CONTROL_URL: control.url,
582
- OMB_CONTROL_TOKEN: control.token,
583
- },
584
- },
585
- };
586
- }
587
- /** No desktop surface (a server, or a Windows desktop): the bot's browser is
588
- * agent-browser, one isolated session per profile or per bot
589
- * (docs/plans/browser-engine.md). Returns null, with the reason logged once,
590
- * when the engine is not on this machine. */
591
- function engineBrowserIntegration(botId, profile) {
499
+ /** The bot's browser for this turn: agent-browser, one isolated session per
500
+ * browser profile or per bot (docs/plans/browser-engine.md). Null, with the
501
+ * reason logged once, when the engine is not on this machine. */
502
+ function browserIntegration(botId, profile) {
592
503
  const status = browserEngineStatus();
593
504
  if (status.kind !== "ready") {
594
505
  if (!engineUnavailableLogged) {
@@ -597,12 +508,11 @@ function engineBrowserIntegration(botId, profile) {
597
508
  }
598
509
  return null;
599
510
  }
511
+ // A profile that no longer exists falls back to the bot's own session.
600
512
  const profileTarget = profile && profile !== "guest" ? browserProfilePartitionTarget(cfg, profile) : null;
601
513
  const partitionId = profile === "guest" ? "guest" : (profileTarget?.partitionId ?? "");
602
514
  const session = browserSessionId(botId, partitionId);
603
515
  return {
604
- connection: null,
605
- capability: null,
606
516
  profile: partitionId,
607
517
  integration: agentBrowserIntegration({
608
518
  binaryPath: status.binaryPath,
@@ -613,13 +523,14 @@ function engineBrowserIntegration(botId, profile) {
613
523
  };
614
524
  }
615
525
  let engineUnavailableLogged = false;
526
+ let browserEngineInstall = null;
527
+ let browserEngineInstallError = null;
616
528
  export function browserEngineSummary() {
617
- if (availableBrowserConnection())
618
- return { kind: "desktop" };
619
529
  const status = browserEngineStatus();
530
+ const progress = { ...(browserEngineInstall ? { installing: true } : {}), ...(browserEngineInstallError ? { installError: browserEngineInstallError } : {}) };
620
531
  return status.kind === "ready"
621
- ? { kind: "headless", version: status.version }
622
- : { kind: "unavailable", reason: status.reason, installable: status.installable };
532
+ ? { kind: "engine", version: status.version, ...progress }
533
+ : { kind: "unavailable", reason: status.reason, installable: status.installable, ...progress };
623
534
  }
624
535
  function phoneIntegration() {
625
536
  const env = { ...AGENTS_NODE_FLAG };
@@ -680,7 +591,7 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
680
591
  ...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown() }).strict()),
681
592
  ]);
682
593
  /** The loopback endpoint a bot's computer proxy polls before acting. */
683
- function controlIntegration(botId, threadId, generation) {
594
+ function controlIntegration(botId, threadId, generation, localVmTarget) {
684
595
  return {
685
596
  url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
686
597
  token: mintInternalCapability({
@@ -689,6 +600,7 @@ function controlIntegration(botId, threadId, generation) {
689
600
  generation,
690
601
  depth: 0,
691
602
  kind: "computer",
603
+ ...(localVmTarget ? { localVmTarget } : {}),
692
604
  skillAuthoring: false,
693
605
  createdBots: 0,
694
606
  }),
@@ -1959,7 +1871,9 @@ const watchdog = new TurnWatchdog({
1959
1871
  stallMs: TURN_STALL_MS,
1960
1872
  checkMs: 60_000,
1961
1873
  onStall: (turn) => {
1962
- void releaseBrowserCapabilityForThread(turn.threadId);
1874
+ // Room targets carry an invocation identity; only those claims belong
1875
+ // to the room grace cleanup added here.
1876
+ const stalledVmTarget = groupSpeakers.has(turn.threadId) ? localVmThreadTargets.get(turn.threadId) : undefined;
1963
1877
  revokeInternalCapabilitiesForThread(turn.threadId);
1964
1878
  repeats.settle(turn.threadId);
1965
1879
  const bot = store.bot(turn.botId);
@@ -1988,6 +1902,9 @@ const watchdog = new TurnWatchdog({
1988
1902
  retry.unref?.();
1989
1903
  return;
1990
1904
  }
1905
+ if (stalledVmTarget && localVmThreadTargets.get(turn.threadId) === stalledVmTarget) {
1906
+ releaseLocalVmThread(turn.threadId);
1907
+ }
1991
1908
  const group = store.groupByThread(turn.threadId);
1992
1909
  const speaker = groupSpeakers.get(turn.threadId);
1993
1910
  if (group && group.busyBotId === turn.botId && speaker?.botId === turn.botId) {
@@ -2083,7 +2000,6 @@ bus.subscribe((event) => {
2083
2000
  watchdog.setWaitingOnHuman(event.threadId, false);
2084
2001
  else if (event.type === "turn.completed") {
2085
2002
  watchdog.settle(event.threadId);
2086
- void releaseBrowserCapabilityForThread(event.threadId);
2087
2003
  revokeInternalCapabilityForProviderEvent(event);
2088
2004
  }
2089
2005
  else if (event.type === "session.exited") {
@@ -2093,7 +2009,6 @@ bus.subscribe((event) => {
2093
2009
  const directBotBusy = store.botByThread(event.threadId)?.busy === true;
2094
2010
  const roomBusy = Boolean(store.groupByThread(event.threadId)?.busyBotId);
2095
2011
  if (!directBotBusy && !roomBusy) {
2096
- void releaseBrowserCapabilityForThread(event.threadId);
2097
2012
  }
2098
2013
  }
2099
2014
  else
@@ -3949,13 +3864,7 @@ async function startTurn(botId, text, opts) {
3949
3864
  liveBot.browser !== false &&
3950
3865
  instance.adapter.capabilities.browserMcp === true) {
3951
3866
  const selectedProfile = liveBot.browserProfile;
3952
- browser = await browserIntegration(bot.id, selectedProfile, threadId, dispatchClaimId, () => {
3953
- const current = store.bot(bot.id);
3954
- return (directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId) &&
3955
- builtInBrowserEnabled(cfg) &&
3956
- current?.browser !== false &&
3957
- current?.browserProfile === selectedProfile);
3958
- }, dispatchClaimId);
3867
+ browser = browserIntegration(bot.id, selectedProfile);
3959
3868
  if (browser)
3960
3869
  integrations.browser = browser.integration;
3961
3870
  }
@@ -4040,10 +3949,6 @@ async function startTurn(botId, text, opts) {
4040
3949
  // after its own turn.completed would never be torn down — it would
4041
3950
  // keep polling the box forever, carrying dead per-turn state. busy
4042
3951
  // is flipped false in the fold, so it is the honest "still running".
4043
- if (!previewCapture && browser?.connection && browser.capability) {
4044
- const { connection, capability } = browser;
4045
- previewCapture = () => browserScreenshot(connection, capability, fetch);
4046
- }
4047
3952
  if (previewCapture && store.bot(bot.id)?.busy) {
4048
3953
  startScreenPoller(bot.id, previewCapture, { screenIsTheWork: instance.driverKind === "boxAgent" });
4049
3954
  }
@@ -4058,7 +3963,6 @@ async function startTurn(botId, text, opts) {
4058
3963
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
4059
3964
  clearDirectTurnDispatch(bot.id, dispatchClaimId);
4060
3965
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
4061
- await releaseBrowserCapabilityForThread(threadId, dispatchClaimId);
4062
3966
  const ownsLatestGeneration = directTurnGenerationByBot.get(bot.id) === dispatchClaimId;
4063
3967
  if (ownsLatestGeneration) {
4064
3968
  releaseLocalVmThread(threadId);
@@ -4222,7 +4126,6 @@ async function interruptRoutineGroupGoal(groupId, threadId, outcome) {
4222
4126
  const bot = speaker ? store.bot(speaker.botId) : undefined;
4223
4127
  cancelGroupTurnOperations(groupId, threadId, outcome);
4224
4128
  revokeInternalCapabilitiesForThread(threadId);
4225
- await releaseBrowserCapabilityForThread(threadId);
4226
4129
  await (bot ? registry.get(bot.modelSelection.instanceId) : undefined)
4227
4130
  ?.adapter.interruptTurn(threadId)
4228
4131
  .catch(() => { });
@@ -4251,7 +4154,6 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4251
4154
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
4252
4155
  cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
4253
4156
  const results = await Promise.allSettled([
4254
- releaseBrowserCapabilityForThread(groupTurn.threadId),
4255
4157
  registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(groupTurn.threadId),
4256
4158
  ]);
4257
4159
  closeOpenApprovals(groupTurn.threadId);
@@ -4264,7 +4166,6 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4264
4166
  const threadId = directClaim?.threadId ?? bot.threadId;
4265
4167
  revokeInternalCapabilitiesForThread(threadId);
4266
4168
  const results = await Promise.allSettled([
4267
- releaseBrowserCapabilityForThread(threadId),
4268
4169
  registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(threadId),
4269
4170
  ]);
4270
4171
  closeOpenApprovals(threadId);
@@ -4318,7 +4219,6 @@ routines = new RoutineManager({
4318
4219
  ? registry.get(bot.modelSelection.instanceId)
4319
4220
  : null;
4320
4221
  try {
4321
- await releaseBrowserCapabilityForThread(threadId);
4322
4222
  await instance?.adapter.interruptTurn(threadId);
4323
4223
  }
4324
4224
  finally {
@@ -4730,6 +4630,15 @@ setupRetry = 0) {
4730
4630
  return true;
4731
4631
  }
4732
4632
  const internalGeneration = beginInternalCapabilityGeneration(threadId);
4633
+ let roomVmTarget = null;
4634
+ let retainRoomVmLease = false;
4635
+ let roomSpeaker;
4636
+ let providerDispatched = false;
4637
+ const releaseRoomVmLease = () => {
4638
+ if (roomVmTarget && localVmThreadTargets.get(threadId) === roomVmTarget)
4639
+ releaseLocalVmThread(threadId);
4640
+ roomVmTarget = null;
4641
+ };
4733
4642
  try {
4734
4643
  const integrations = {};
4735
4644
  const skillAuthoring = skillRecorderEnabled(cfg) &&
@@ -4879,19 +4788,7 @@ setupRetry = 0) {
4879
4788
  readyBot.browser !== false &&
4880
4789
  instance.adapter.capabilities.browserMcp === true) {
4881
4790
  const selectedProfile = readyBot.browserProfile;
4882
- const browser = await browserIntegration(readyBot.id, selectedProfile, threadId, internalGeneration, () => {
4883
- const currentBot = store.bot(readyBot.id);
4884
- const currentGroup = store.group(readyGroup.id);
4885
- const stillOwnsThread = currentGroup?.dm
4886
- ? currentGroup.threadId === threadId
4887
- : Boolean(currentGroup && store.groupTaskByThread(currentGroup.id, threadId));
4888
- return (!isCancelled?.() &&
4889
- stillOwnsThread &&
4890
- currentBot?.busy === true &&
4891
- builtInBrowserEnabled(cfg) &&
4892
- currentBot.browser !== false &&
4893
- currentBot.browserProfile === selectedProfile);
4894
- });
4791
+ const browser = browserIntegration(readyBot.id, selectedProfile);
4895
4792
  if (browser)
4896
4793
  integrations.browser = browser.integration;
4897
4794
  }
@@ -4900,7 +4797,6 @@ setupRetry = 0) {
4900
4797
  // room's setup claim so no provider turn starts after Stop returned.
4901
4798
  const browserReadyBot = store.bot(readyBot.id);
4902
4799
  if (isCancelled?.() || !browserReadyBot || !browserReadyBot.busy) {
4903
- await releaseBrowserCapabilityForThread(threadId);
4904
4800
  if (browserReadyBot?.busy) {
4905
4801
  store.setActivity(browserReadyBot.id, "idle");
4906
4802
  retryDelegationsWaitingOn(browserReadyBot.id);
@@ -4908,7 +4804,44 @@ setupRetry = 0) {
4908
4804
  return false;
4909
4805
  }
4910
4806
  store.patchGroup(readyGroup.id, { busyBotId: bot.id }); // the store's change stream carries the frame
4911
- groupSpeakers.set(threadId, { botId: bot.id, name: bot.name, color: bot.color });
4807
+ roomSpeaker = { botId: bot.id, name: bot.name, color: bot.color };
4808
+ groupSpeakers.set(threadId, roomSpeaker);
4809
+ // Room and Goal turns use the speaker's desktop, never the coordinator's.
4810
+ // Claim the same lease as direct turns before asynchronous VM setup.
4811
+ if (readyBot.computer === "vm") {
4812
+ if (instance.adapter.capabilities.computerMcp !== true || instance.driverKind === "boxAgent") {
4813
+ throw new Error("this model engine cannot use the Local VM");
4814
+ }
4815
+ // A distinct identity fences cleanup even in shared mode on the same room thread.
4816
+ const target = { ...localVmTargetForBot(readyBot.id) };
4817
+ if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(target.key)) {
4818
+ throw new Error("this Local VM is being started, stopped, or replaced");
4819
+ }
4820
+ if (!localVmLeaseFor(target).claim(threadId, readyBot.id, localVmOwnerBusy)) {
4821
+ throw new Error("this Local VM is already being used by another turn");
4822
+ }
4823
+ roomVmTarget = target;
4824
+ localVmThreadTargets.set(threadId, target);
4825
+ localVmActiveThreads.set(target.key, threadId);
4826
+ localVmIdleFor(target).touch();
4827
+ const setupIsCurrent = () => !isCancelled?.() &&
4828
+ groupSpeakers.get(threadId) === roomSpeaker &&
4829
+ activeInternalGenerationByThread.get(threadId) === internalGeneration &&
4830
+ store.group(readyGroup.id)?.memberIds.includes(readyBot.id) === true &&
4831
+ store.bot(readyBot.id)?.busy === true &&
4832
+ store.group(readyGroup.id)?.busyBotId === readyBot.id;
4833
+ const vm = await readyLocalVmForTurn(readyBot.id, target, setupIsCurrent);
4834
+ if (!setupIsCurrent()) {
4835
+ return false;
4836
+ }
4837
+ if (!vm.ready || !vm.runtime)
4838
+ throw new Error(vm.problem ?? "the Local VM is not ready");
4839
+ const owner = localVmLeaseFor(target).current(localVmOwnerBusy);
4840
+ if (owner?.threadId !== threadId || owner.botId !== readyBot.id) {
4841
+ throw new Error("the Local VM lease expired while preparing the turn");
4842
+ }
4843
+ integrations.localComputer = containerComputerMcp(vm.runtime, controlIntegration(readyBot.id, threadId, internalGeneration, target), target);
4844
+ }
4912
4845
  const roster = readyGroup.memberIds
4913
4846
  .map((id) => store.bot(id))
4914
4847
  .filter((b) => Boolean(b))
@@ -4959,6 +4892,7 @@ setupRetry = 0) {
4959
4892
  store.patchBot(bot.id, { soulDrift: drift.drift });
4960
4893
  }
4961
4894
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
4895
+ { id: "computer", label: "Computer", text: computerPrompt(roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
4962
4896
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
4963
4897
  { id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
4964
4898
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
@@ -4984,7 +4918,6 @@ setupRetry = 0) {
4984
4918
  if (isCancelled?.() ||
4985
4919
  store.group(group.id)?.busyBotId !== bot.id ||
4986
4920
  store.bot(bot.id)?.busy !== true) {
4987
- await releaseBrowserCapabilityForThread(threadId);
4988
4921
  if (store.group(group.id)?.busyBotId === bot.id) {
4989
4922
  groupSpeakers.delete(threadId);
4990
4923
  store.patchGroup(group.id, { busyBotId: null, unread: true });
@@ -5016,7 +4949,6 @@ setupRetry = 0) {
5016
4949
  let unregisterStall = () => { };
5017
4950
  const deadline = new RoomTurnDeadline(timeoutMinutes, () => {
5018
4951
  abandonProviderTurn();
5019
- void releaseBrowserCapabilityForThread(threadId);
5020
4952
  void instance.adapter.interruptTurn(threadId).catch(() => { });
5021
4953
  store.appendMessage(threadId, {
5022
4954
  role: "bot",
@@ -5068,6 +5000,7 @@ setupRetry = 0) {
5068
5000
  });
5069
5001
  watchdog.watch(threadId, bot.id);
5070
5002
  onProviderHandshakeStarted?.();
5003
+ providerDispatched = true;
5071
5004
  guardTurnDispatch(instance.adapter.sendTurn({
5072
5005
  threadId,
5073
5006
  text,
@@ -5081,7 +5014,6 @@ setupRetry = 0) {
5081
5014
  // Stop may have landed while the adapter was authenticating, before
5082
5015
  // it had an active process for the first interrupt to reach. Now that
5083
5016
  // sendTurn completed setup, revoke again and interrupt the real turn.
5084
- await releaseBrowserCapabilityForThread(threadId);
5085
5017
  await instance.adapter.interruptTurn(threadId).catch(() => { });
5086
5018
  })
5087
5019
  .then((dispatch) => {
@@ -5121,6 +5053,9 @@ setupRetry = 0) {
5121
5053
  // work so a retained proxy from this member cannot act during the next
5122
5054
  // member's generation.
5123
5055
  revokeInternalCapabilityGeneration(threadId, internalGeneration);
5056
+ retainRoomVmLease = outcome === "timed_out" || outcome === "stalled";
5057
+ if (!retainRoomVmLease)
5058
+ releaseRoomVmLease();
5124
5059
  if (orchestration) {
5125
5060
  orchestration.result.replyText = replyText.trim();
5126
5061
  orchestration.result.outcome = outcome;
@@ -5160,6 +5095,7 @@ setupRetry = 0) {
5160
5095
  retry.unref?.();
5161
5096
  return;
5162
5097
  }
5098
+ releaseRoomVmLease();
5163
5099
  const currentGroup = store.group(group.id);
5164
5100
  const speaker = groupSpeakers.get(threadId);
5165
5101
  if (currentGroup?.busyBotId === bot.id && speaker?.botId === bot.id) {
@@ -5195,7 +5131,6 @@ setupRetry = 0) {
5195
5131
  if (outcome === "dispatch_failed") {
5196
5132
  if (skillAuthoring)
5197
5133
  skillAuthoringClaim.claimed = false;
5198
- await releaseBrowserCapabilityForThread(threadId);
5199
5134
  // No turn.completed follows a rejected room dispatch. Anything that was
5200
5135
  // queued while this bot briefly owned the room must be retried now.
5201
5136
  drainQueuedSends();
@@ -5255,10 +5190,38 @@ setupRetry = 0) {
5255
5190
  }
5256
5191
  return true;
5257
5192
  }
5193
+ catch (error) {
5194
+ if (providerDispatched || !roomSpeaker)
5195
+ throw error;
5196
+ const message = error instanceof Error ? error.message : "Local VM setup failed";
5197
+ store.appendMessage(threadId, {
5198
+ role: "bot", kind: "activity",
5199
+ from: { botId: bot.id, name: bot.name, color: bot.color },
5200
+ tool: { name: `error: ${message}`, ok: false },
5201
+ });
5202
+ onDispatchError?.(message);
5203
+ if (orchestration)
5204
+ orchestration.result.outcome = "dispatch_failed";
5205
+ return false;
5206
+ }
5258
5207
  finally {
5259
5208
  // Covers connector/setup failures, cancellation before dispatch, and all
5260
5209
  // other early returns that never produce a provider terminal event.
5261
5210
  revokeInternalCapabilityGeneration(threadId, internalGeneration);
5211
+ if (!retainRoomVmLease)
5212
+ releaseRoomVmLease();
5213
+ if (!providerDispatched && roomSpeaker && groupSpeakers.get(threadId) === roomSpeaker) {
5214
+ groupSpeakers.delete(threadId);
5215
+ if (store.group(group.id)?.busyBotId === bot.id)
5216
+ store.patchGroup(group.id, { busyBotId: null });
5217
+ if (store.bot(bot.id)?.busy) {
5218
+ store.setActivity(bot.id, "idle");
5219
+ retryDelegationsWaitingOn(bot.id);
5220
+ }
5221
+ drainQueuedSends();
5222
+ drainConnectorResumes();
5223
+ drainSecretResumes();
5224
+ }
5262
5225
  }
5263
5226
  }
5264
5227
  async function runGroupGoalStep(args) {
@@ -6493,13 +6456,15 @@ async function localVmPayload(target) {
6493
6456
  * instance cap; creating past it would quietly do what the lifecycle route
6494
6457
  * refuses.
6495
6458
  */
6496
- async function readyLocalVmForTurn(botId, target) {
6459
+ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
6497
6460
  let status = await containerComputerStatus(undefined, undefined, target);
6461
+ if (!isCurrent())
6462
+ return status;
6498
6463
  if (status.ready || !localVmRecreatableOnDemand(status))
6499
6464
  return status;
6500
6465
  if (target.key !== SHARED_LOCAL_VM_TARGET.key) {
6501
6466
  const count = await existingPerBotLocalVmCount(status.runtime);
6502
- if (count >= localVmMaxInstances(cfg))
6467
+ if (!isCurrent() || count >= localVmMaxInstances(cfg))
6503
6468
  return status;
6504
6469
  }
6505
6470
  broadcast({ kind: "computer", botId, state: "provisioning" });
@@ -6522,8 +6487,10 @@ async function readyLocalVmForTurn(botId, target) {
6522
6487
  // the turn is the whole point: a person who has been away eight hours should
6523
6488
  // not have to send their message twice.
6524
6489
  const deadline = Date.now() + LOCAL_VM_DESKTOP_WAIT_MS;
6525
- while (!status.ready && status.container === "running" && Date.now() < deadline) {
6490
+ while (isCurrent() && !status.ready && status.container === "running" && Date.now() < deadline) {
6526
6491
  await new Promise((resolve) => setTimeout(resolve, 2_000));
6492
+ if (!isCurrent())
6493
+ break;
6527
6494
  status = await containerComputerStatus(undefined, undefined, target);
6528
6495
  }
6529
6496
  return status;
@@ -6599,7 +6566,6 @@ async function reloadProviders() {
6599
6566
  // one synchronous step before the first teardown await, including room/task
6600
6567
  // threads that are not a bot's default DM.
6601
6568
  revokeAllInternalCapabilities();
6602
- await releaseAllBrowserCapabilities();
6603
6569
  bus.detachAll();
6604
6570
  await registry.disposeAll();
6605
6571
  await registry.load(instanceConfigs(cfg));
@@ -9257,7 +9223,6 @@ const handleRequest = async (req, res) => {
9257
9223
  cancelGroupTurnOperations(group.id, threadId);
9258
9224
  for (const { threadId, instance } of interruptTargets) {
9259
9225
  revokeInternalCapabilitiesForThread(threadId);
9260
- await releaseBrowserCapabilityForThread(threadId);
9261
9226
  await instance?.adapter.interruptTurn(threadId).catch(() => { });
9262
9227
  closeOpenApprovals(threadId);
9263
9228
  }
@@ -9855,7 +9820,6 @@ const handleRequest = async (req, res) => {
9855
9820
  cancelDirectTurnDispatch(bot.id, routineRun.threadId);
9856
9821
  if (routineRun.threadId) {
9857
9822
  revokeInternalCapabilitiesForThread(routineRun.threadId);
9858
- await releaseBrowserCapabilityForThread(routineRun.threadId);
9859
9823
  }
9860
9824
  await routines.cancelRun(routineRun.id);
9861
9825
  return;
@@ -9865,7 +9829,6 @@ const handleRequest = async (req, res) => {
9865
9829
  if (groupTurn) {
9866
9830
  cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
9867
9831
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
9868
- await releaseBrowserCapabilityForThread(groupTurn.threadId);
9869
9832
  await instance?.adapter.interruptTurn(groupTurn.threadId).catch(() => { });
9870
9833
  closeOpenApprovals(groupTurn.threadId);
9871
9834
  return;
@@ -9873,7 +9836,6 @@ const handleRequest = async (req, res) => {
9873
9836
  const directClaim = cancelDirectTurnDispatch(bot.id);
9874
9837
  const threadId = directClaim?.threadId ?? bot.threadId;
9875
9838
  revokeInternalCapabilitiesForThread(threadId);
9876
- await releaseBrowserCapabilityForThread(threadId);
9877
9839
  await instance?.adapter.interruptTurn(threadId).catch(() => { });
9878
9840
  closeOpenApprovals(threadId);
9879
9841
  }));
@@ -10000,7 +9962,7 @@ const handleRequest = async (req, res) => {
10000
9962
  // or unreadable journal therefore rejects the delete with the bot and
10001
9963
  // all of its live work untouched. The intent is aborted if a later
10002
9964
  // pre-delete side effect fails, and committed only after Store deletion.
10003
- const browserCleanupRequest = utilityParentPort ? browserCleanup.prepare("bot", bot.id) : null;
9965
+ const browserCleanupRequest = browserCleanup.prepare("bot", bot.id);
10004
9966
  try {
10005
9967
  // a running turn dies with its bot
10006
9968
  const directClaim = cancelDirectTurnDispatch(bot.id);
@@ -10010,7 +9972,6 @@ const handleRequest = async (req, res) => {
10010
9972
  // check is revalidated after its body arrives and must fail closed.
10011
9973
  revokeInternalCapabilitiesForThread(directThreadId);
10012
9974
  directTurnGenerationByBot.delete(bot.id);
10013
- await releaseBrowserCapabilitiesForBot(bot.id);
10014
9975
  await registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(directThreadId).catch(() => { });
10015
9976
  closeOpenApprovals(directThreadId);
10016
9977
  // Deletion removes the thread before a late turn.completed can fold
@@ -10771,7 +10732,6 @@ const handleRequest = async (req, res) => {
10771
10732
  cancelDirectTurnDispatch(bot.id, routineRun.threadId ?? expectedThreadId);
10772
10733
  if (routineRun.threadId) {
10773
10734
  revokeInternalCapabilitiesForThread(routineRun.threadId);
10774
- await releaseBrowserCapabilityForThread(routineRun.threadId);
10775
10735
  }
10776
10736
  await routines.cancelRun(routineRun.id);
10777
10737
  return json(res, 200, { ok: true });
@@ -10786,7 +10746,6 @@ const handleRequest = async (req, res) => {
10786
10746
  }
10787
10747
  cancelGroupTurnOperations(busyGroup.group.id, busyGroup.threadId);
10788
10748
  revokeInternalCapabilitiesForThread(busyGroup.threadId);
10789
- await releaseBrowserCapabilityForThread(busyGroup.threadId);
10790
10749
  await instance?.adapter.interruptTurn(busyGroup.threadId).catch(() => { });
10791
10750
  closeOpenApprovals(busyGroup.threadId);
10792
10751
  return json(res, 200, { ok: true });
@@ -10800,7 +10759,6 @@ const handleRequest = async (req, res) => {
10800
10759
  const cancelledDirect = cancelDirectTurnDispatch(bot.id, expectedThreadId);
10801
10760
  const directThreadId = cancelledDirect?.threadId ?? bot.threadId;
10802
10761
  revokeInternalCapabilitiesForThread(directThreadId);
10803
- await releaseBrowserCapabilityForThread(directThreadId);
10804
10762
  await instance?.adapter.interruptTurn(directThreadId).catch(() => { });
10805
10763
  closeOpenApprovals(directThreadId);
10806
10764
  return json(res, 200, { ok: true });
@@ -11088,6 +11046,26 @@ const handleRequest = async (req, res) => {
11088
11046
  if (method === "GET" && path === "/api/health") {
11089
11047
  return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR) });
11090
11048
  }
11049
+ // The bots' browser engine: install it on this machine (agent-browser +
11050
+ // a Chrome for Testing, a one-time download), or ask how that is going.
11051
+ // One install at a time; the config frame's browserEngine tells the rest.
11052
+ if (method === "POST" && path === "/api/browser-engine/install") {
11053
+ if (!browserEngineInstall) {
11054
+ const status = browserEngineStatus();
11055
+ if (status.kind === "unavailable" && !status.installable)
11056
+ return json(res, 409, { error: status.reason });
11057
+ browserEngineInstallError = null;
11058
+ browserEngineInstall = (async () => {
11059
+ const binary = resolveAgentBrowserBinary() ?? await installAgentBrowserBinary({ log: (line) => console.log(line) });
11060
+ await ensureChrome(binary, { log: (line) => console.log(line) });
11061
+ })().then(() => { browserEngineInstallError = null; }, (error) => { browserEngineInstallError = error instanceof Error ? error.message : String(error); }).finally(() => {
11062
+ browserEngineInstall = null;
11063
+ broadcast({ kind: "config", ...configStatus() });
11064
+ });
11065
+ broadcast({ kind: "config", ...configStatus() });
11066
+ }
11067
+ return json(res, 202, { installing: true });
11068
+ }
11091
11069
  // Which edition this server runs and why (see server/enterprise.ts). Read-only.
11092
11070
  if (method === "GET" && path === "/api/edition") {
11093
11071
  return json(res, 200, editionStatus());
@@ -11649,13 +11627,11 @@ const handleRequest = async (req, res) => {
11649
11627
  }
11650
11628
  const browserCleanupRequests = [];
11651
11629
  try {
11652
- if (utilityParentPort) {
11653
- for (const profileId of removedBrowserProfileIds) {
11654
- const target = browserProfilePartitionTarget(cfg, profileId);
11655
- if (!target)
11656
- throw new Error(`browser profile cleanup target “${profileId}” is unavailable`);
11657
- browserCleanupRequests.push(browserCleanup.prepare("profile", target.profileId, target.partitionId));
11658
- }
11630
+ for (const profileId of removedBrowserProfileIds) {
11631
+ const target = browserProfilePartitionTarget(cfg, profileId);
11632
+ if (!target)
11633
+ throw new Error(`browser profile cleanup target “${profileId}” is unavailable`);
11634
+ browserCleanupRequests.push(browserCleanup.prepare("profile", target.profileId, target.partitionId));
11659
11635
  }
11660
11636
  }
11661
11637
  catch (error) {
@@ -11763,7 +11739,6 @@ const handleRequest = async (req, res) => {
11763
11739
  let mandatoryError = null;
11764
11740
  if (disablingBuiltInBrowser) {
11765
11741
  try {
11766
- await releaseAllBrowserCapabilities();
11767
11742
  }
11768
11743
  catch (error) {
11769
11744
  mandatoryError = error;
@@ -12241,7 +12216,6 @@ const gracefulShutdown = createGracefulShutdown({
12241
12216
  webhookIngress?.server.close();
12242
12217
  tunnelListener?.close();
12243
12218
  },
12244
- () => releaseAllBrowserCapabilities(),
12245
12219
  () => registry.disposeAll(),
12246
12220
  () => flushAllProfileHistory(),
12247
12221
  ],