openmausbot 0.1.57 → 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.
Files changed (30) hide show
  1. package/dist/assets/index-Cvk0KgFW.js +264 -0
  2. package/dist/assets/{index-jWqt5dmY.js → index-D914UwuG.js} +1 -1
  3. package/dist/assets/index-oPMv6a_k.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/container-mcp.js +1 -2
  6. package/dist-server/index.js +1089 -825
  7. package/dist-server/openmausbot.js +15219 -305
  8. package/dist-server/pair-cli.js +15219 -305
  9. package/dist-server/proxy-paths.js +0 -1
  10. package/dist-server/server/auto-approve.js +18 -4
  11. package/dist-server/server/browser-engine-release.js +44 -0
  12. package/dist-server/server/browser-engine.js +216 -0
  13. package/dist-server/server/cli.js +55 -1
  14. package/dist-server/server/composio.js +43 -21
  15. package/dist-server/server/container-computer.js +32 -7
  16. package/dist-server/server/drivers/antigravity-acp.js +61 -11
  17. package/dist-server/server/drivers/antigravity-runtime.js +2 -2
  18. package/dist-server/server/drivers/antigravity.js +23 -2
  19. package/dist-server/server/drivers/codex-instructions.js +66 -0
  20. package/dist-server/server/drivers/codex.js +84 -49
  21. package/dist-server/server/index.js +198 -183
  22. package/dist-server/server/procs.js +34 -22
  23. package/dist-server/server/proxy-paths.js +0 -1
  24. package/dist-server/vps-container-mcp.js +1 -2
  25. package/package.json +1 -1
  26. package/dist/assets/index-B433sv8K.css +0 -1
  27. package/dist/assets/index-DSh8AIUy.js +0 -264
  28. package/dist-server/drivers/browser-proxy.js +0 -15037
  29. package/dist-server/server/browser-connection.js +0 -187
  30. package/dist-server/server/drivers/browser-proxy.js +0 -520
@@ -84,7 +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";
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";
88
89
  import { captureOutsideHumanControl } from "./private-screen-capture.js";
89
90
  import { screenFrameHash, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
90
91
  import { RoutineRequestService } from "./routine-requests.js";
@@ -202,9 +203,40 @@ function applyDesktopMutationTokenMessage(raw) {
202
203
  }
203
204
  return true;
204
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.
205
209
  const browserCleanup = new BrowserCleanupCoordinator({
206
210
  file: join(DATA_DIR, "browser-cleanups.json"),
207
- 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
+ },
208
240
  });
209
241
  const phoneSecrets = new PhoneSecretBridge(postDesktopPrivateMessage);
210
242
  utilityParentPort?.on("message", (event) => {
@@ -218,8 +250,7 @@ utilityParentPort?.on("message", (event) => {
218
250
  return;
219
251
  if (phoneSecrets.receive(message))
220
252
  return;
221
- if (!applyDesktopBrowserConnectionMessage(message))
222
- composio.applyManagedBrokerMessage(message);
253
+ composio.applyManagedBrokerMessage(message);
223
254
  }
224
255
  catch (error) {
225
256
  console.error(`[desktop-sync] rejected private parent message: ${error instanceof Error ? error.message : String(error)}`);
@@ -299,10 +330,8 @@ function revokeInternalCapabilityForProviderEvent(event) {
299
330
  * length; only capabilities for currently active turns are retained. */
300
331
  function authorizedInternalCapability(header) {
301
332
  const got = Buffer.from(Array.isArray(header) ? "" : (header ?? ""));
302
- const now = Date.now();
303
333
  for (const [token, capability] of internalCapabilities) {
304
- if (capability.orphanExpiresAt <= now ||
305
- activeInternalGenerationByThread.get(capability.threadId) !== capability.generation) {
334
+ if (!internalCapabilityIsActive(capability)) {
306
335
  internalCapabilities.delete(token);
307
336
  continue;
308
337
  }
@@ -313,6 +342,12 @@ function authorizedInternalCapability(header) {
313
342
  return null;
314
343
  }
315
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
+ }
316
351
  return (capability.orphanExpiresAt > Date.now() &&
317
352
  activeInternalGenerationByThread.get(capability.threadId) === capability.generation);
318
353
  }
@@ -367,84 +402,6 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation) {
367
402
  },
368
403
  };
369
404
  }
370
- const browserCapabilitiesByThread = new Map();
371
- const pendingBrowserCapabilityRevocations = new Map();
372
- const BROWSER_REVOCATION_RETRY_MS = [250, 1_000, 3_000, 10_000, 30_000];
373
- async function revokeReleasedBrowserCapability(active, attempt = 0) {
374
- const token = active.capability.token;
375
- try {
376
- await revokeBrowserCapability(active.connection, active.capability);
377
- const pending = pendingBrowserCapabilityRevocations.get(token);
378
- if (pending)
379
- clearTimeout(pending.timer);
380
- pendingBrowserCapabilityRevocations.delete(token);
381
- }
382
- catch (error) {
383
- if (Date.now() >= active.capability.expiresAt) {
384
- pendingBrowserCapabilityRevocations.delete(token);
385
- return;
386
- }
387
- if (attempt === 0) {
388
- console.error(`[browser] could not revoke turn capability; retrying until its absolute expiry: ${error instanceof Error ? error.message : String(error)}`);
389
- }
390
- 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()));
391
- const timer = setTimeout(() => {
392
- const pending = pendingBrowserCapabilityRevocations.get(token);
393
- if (!pending || pending.timer !== timer)
394
- return;
395
- void revokeReleasedBrowserCapability(active, attempt + 1);
396
- }, delay);
397
- timer.unref?.();
398
- const previous = pendingBrowserCapabilityRevocations.get(token);
399
- if (previous)
400
- clearTimeout(previous.timer);
401
- pendingBrowserCapabilityRevocations.set(token, { active, attempt: attempt + 1, timer });
402
- }
403
- }
404
- async function releaseBrowserCapabilityForThread(threadId, expectedOwnerId) {
405
- const active = browserCapabilitiesByThread.get(threadId);
406
- if (!active || (expectedOwnerId !== undefined && active.ownerId !== expectedOwnerId))
407
- return;
408
- browserCapabilitiesByThread.delete(threadId);
409
- await revokeReleasedBrowserCapability(active);
410
- }
411
- async function releaseBrowserCapabilitiesForBot(botId) {
412
- const threads = [...browserCapabilitiesByThread]
413
- .filter(([, active]) => active.botId === botId)
414
- .map(([threadId]) => threadId);
415
- await Promise.all(threads.map((threadId) => releaseBrowserCapabilityForThread(threadId)));
416
- }
417
- async function releaseAllBrowserCapabilities() {
418
- const active = [...browserCapabilitiesByThread.values()];
419
- browserCapabilitiesByThread.clear();
420
- const connections = new Map();
421
- for (const entry of active) {
422
- connections.set(`${entry.connection.url}:${entry.connection.token}`, entry.connection);
423
- }
424
- for (const pending of pendingBrowserCapabilityRevocations.values()) {
425
- connections.set(`${pending.active.connection.url}:${pending.active.connection.token}`, pending.active.connection);
426
- }
427
- await Promise.all([...connections.values()].map(async (connection) => {
428
- try {
429
- // Master clear is atomic at the host. It also invalidates a token whose
430
- // earlier per-turn revoke timed out, which is essential for feature-off
431
- // and graceful-shutdown boundaries.
432
- await clearBrowserCapabilities(connection);
433
- for (const [token, pending] of pendingBrowserCapabilityRevocations) {
434
- if (pending.active.connection.url === connection.url &&
435
- pending.active.connection.token === connection.token) {
436
- clearTimeout(pending.timer);
437
- pendingBrowserCapabilityRevocations.delete(token);
438
- }
439
- }
440
- }
441
- catch {
442
- await Promise.all(active
443
- .filter((entry) => entry.connection.url === connection.url && entry.connection.token === connection.token)
444
- .map((entry) => revokeReleasedBrowserCapability(entry)));
445
- }
446
- }));
447
- }
448
405
  class DirectTurnSetupCancelled extends Error {
449
406
  }
450
407
  const directTurnDispatchClaims = new Map();
@@ -539,50 +496,42 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
539
496
  // start early enough for those old events to settle the replacement.
540
497
  return claim;
541
498
  }
542
- async function browserIntegration(botId, profile, threadId, generation, stillValid = () => true, ownerId = randomUUID()) {
543
- const connection = availableBrowserConnection();
544
- if (!connection)
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) {
503
+ const status = browserEngineStatus();
504
+ if (status.kind !== "ready") {
505
+ if (!engineUnavailableLogged) {
506
+ engineUnavailableLogged = true;
507
+ console.warn(`${describeBrowserEngine(status)}; bots get no browser tools until it is installed`);
508
+ }
545
509
  return null;
546
- const control = controlIntegration(botId, threadId, generation);
510
+ }
547
511
  // A profile that no longer exists falls back to the bot's own session.
548
- // Canonical ids belong to config/bot references; Electron must receive the
549
- // exact immutable partition inherited from #567 so an upgrade cannot move
550
- // a bot into another account. Guest remains a throwaway partition.
551
- const profileTarget = profile && profile !== "guest"
552
- ? browserProfilePartitionTarget(cfg, profile)
553
- : null;
512
+ const profileTarget = profile && profile !== "guest" ? browserProfilePartitionTarget(cfg, profile) : null;
554
513
  const partitionId = profile === "guest" ? "guest" : (profileTarget?.partitionId ?? "");
555
- await releaseBrowserCapabilityForThread(threadId);
556
- const capability = await registerBrowserCapability(connection, botId, partitionId);
557
- const active = { botId, ownerId, connection, capability };
558
- // Registration crosses a process boundary. Stop/delete/config changes can
559
- // land while the desktop host is minting the token; revalidate in the same
560
- // event-loop turn that publishes it. If ownership was lost, no agent ever
561
- // receives the bearer and the just-created token is revoked immediately.
562
- if (!stillValid()) {
563
- await revokeReleasedBrowserCapability(active);
564
- return null;
565
- }
566
- browserCapabilitiesByThread.set(threadId, active);
514
+ const session = browserSessionId(botId, partitionId);
567
515
  return {
568
- connection,
569
- capability,
570
516
  profile: partitionId,
571
- integration: {
572
- command: process.execPath,
573
- args: [SPAWNED_PROXIES.browser],
574
- env: {
575
- ...AGENTS_NODE_FLAG,
576
- OMB_BROWSER_URL: connection.url,
577
- OMB_BROWSER_TOKEN: capability.token,
578
- OMB_BROWSER_PROFILE: partitionId,
579
- OMB_BOT_ID: botId,
580
- OMB_CONTROL_URL: control.url,
581
- OMB_CONTROL_TOKEN: control.token,
582
- },
583
- },
517
+ integration: agentBrowserIntegration({
518
+ binaryPath: status.binaryPath,
519
+ session,
520
+ encryptionKey: browserEngineEncryptionKey(),
521
+ persistent: profile !== "guest",
522
+ }),
584
523
  };
585
524
  }
525
+ let engineUnavailableLogged = false;
526
+ let browserEngineInstall = null;
527
+ let browserEngineInstallError = null;
528
+ export function browserEngineSummary() {
529
+ const status = browserEngineStatus();
530
+ const progress = { ...(browserEngineInstall ? { installing: true } : {}), ...(browserEngineInstallError ? { installError: browserEngineInstallError } : {}) };
531
+ return status.kind === "ready"
532
+ ? { kind: "engine", version: status.version, ...progress }
533
+ : { kind: "unavailable", reason: status.reason, installable: status.installable, ...progress };
534
+ }
586
535
  function phoneIntegration() {
587
536
  const env = { ...AGENTS_NODE_FLAG };
588
537
  if (process.env.OMB_ADB_PATH)
@@ -642,7 +591,7 @@ const routineRequestEnvelopeSchema = z.discriminatedUnion("action", [
642
591
  ...["pause", "resume", "run_now", "delete"].map((action) => z.object({ ...routineRequestSourceSchema, action: z.literal(action), routineId: z.unknown() }).strict()),
643
592
  ]);
644
593
  /** The loopback endpoint a bot's computer proxy polls before acting. */
645
- function controlIntegration(botId, threadId, generation) {
594
+ function controlIntegration(botId, threadId, generation, localVmTarget) {
646
595
  return {
647
596
  url: `http://127.0.0.1:${PORT}/api/internal/computer-control?botId=${encodeURIComponent(botId)}`,
648
597
  token: mintInternalCapability({
@@ -651,6 +600,7 @@ function controlIntegration(botId, threadId, generation) {
651
600
  generation,
652
601
  depth: 0,
653
602
  kind: "computer",
603
+ ...(localVmTarget ? { localVmTarget } : {}),
654
604
  skillAuthoring: false,
655
605
  createdBots: 0,
656
606
  }),
@@ -1921,7 +1871,9 @@ const watchdog = new TurnWatchdog({
1921
1871
  stallMs: TURN_STALL_MS,
1922
1872
  checkMs: 60_000,
1923
1873
  onStall: (turn) => {
1924
- 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;
1925
1877
  revokeInternalCapabilitiesForThread(turn.threadId);
1926
1878
  repeats.settle(turn.threadId);
1927
1879
  const bot = store.bot(turn.botId);
@@ -1950,6 +1902,9 @@ const watchdog = new TurnWatchdog({
1950
1902
  retry.unref?.();
1951
1903
  return;
1952
1904
  }
1905
+ if (stalledVmTarget && localVmThreadTargets.get(turn.threadId) === stalledVmTarget) {
1906
+ releaseLocalVmThread(turn.threadId);
1907
+ }
1953
1908
  const group = store.groupByThread(turn.threadId);
1954
1909
  const speaker = groupSpeakers.get(turn.threadId);
1955
1910
  if (group && group.busyBotId === turn.botId && speaker?.botId === turn.botId) {
@@ -2045,7 +2000,6 @@ bus.subscribe((event) => {
2045
2000
  watchdog.setWaitingOnHuman(event.threadId, false);
2046
2001
  else if (event.type === "turn.completed") {
2047
2002
  watchdog.settle(event.threadId);
2048
- void releaseBrowserCapabilityForThread(event.threadId);
2049
2003
  revokeInternalCapabilityForProviderEvent(event);
2050
2004
  }
2051
2005
  else if (event.type === "session.exited") {
@@ -2055,7 +2009,6 @@ bus.subscribe((event) => {
2055
2009
  const directBotBusy = store.botByThread(event.threadId)?.busy === true;
2056
2010
  const roomBusy = Boolean(store.groupByThread(event.threadId)?.busyBotId);
2057
2011
  if (!directBotBusy && !roomBusy) {
2058
- void releaseBrowserCapabilityForThread(event.threadId);
2059
2012
  }
2060
2013
  }
2061
2014
  else
@@ -3911,13 +3864,7 @@ async function startTurn(botId, text, opts) {
3911
3864
  liveBot.browser !== false &&
3912
3865
  instance.adapter.capabilities.browserMcp === true) {
3913
3866
  const selectedProfile = liveBot.browserProfile;
3914
- browser = await browserIntegration(bot.id, selectedProfile, threadId, dispatchClaimId, () => {
3915
- const current = store.bot(bot.id);
3916
- return (directTurnClaimIsCurrent(bot.id, dispatchClaimId, threadId) &&
3917
- builtInBrowserEnabled(cfg) &&
3918
- current?.browser !== false &&
3919
- current?.browserProfile === selectedProfile);
3920
- }, dispatchClaimId);
3867
+ browser = browserIntegration(bot.id, selectedProfile);
3921
3868
  if (browser)
3922
3869
  integrations.browser = browser.integration;
3923
3870
  }
@@ -4002,10 +3949,6 @@ async function startTurn(botId, text, opts) {
4002
3949
  // after its own turn.completed would never be torn down — it would
4003
3950
  // keep polling the box forever, carrying dead per-turn state. busy
4004
3951
  // is flipped false in the fold, so it is the honest "still running".
4005
- if (!previewCapture && browser) {
4006
- const { connection } = browser;
4007
- previewCapture = () => browserScreenshot(connection, browser.capability, fetch);
4008
- }
4009
3952
  if (previewCapture && store.bot(bot.id)?.busy) {
4010
3953
  startScreenPoller(bot.id, previewCapture, { screenIsTheWork: instance.driverKind === "boxAgent" });
4011
3954
  }
@@ -4020,7 +3963,6 @@ async function startTurn(botId, text, opts) {
4020
3963
  clearCancelledProviderHandshake(threadId, `direct:${dispatchClaimId}`);
4021
3964
  clearDirectTurnDispatch(bot.id, dispatchClaimId);
4022
3965
  revokeInternalCapabilityGeneration(threadId, dispatchClaimId);
4023
- await releaseBrowserCapabilityForThread(threadId, dispatchClaimId);
4024
3966
  const ownsLatestGeneration = directTurnGenerationByBot.get(bot.id) === dispatchClaimId;
4025
3967
  if (ownsLatestGeneration) {
4026
3968
  releaseLocalVmThread(threadId);
@@ -4184,7 +4126,6 @@ async function interruptRoutineGroupGoal(groupId, threadId, outcome) {
4184
4126
  const bot = speaker ? store.bot(speaker.botId) : undefined;
4185
4127
  cancelGroupTurnOperations(groupId, threadId, outcome);
4186
4128
  revokeInternalCapabilitiesForThread(threadId);
4187
- await releaseBrowserCapabilityForThread(threadId);
4188
4129
  await (bot ? registry.get(bot.modelSelection.instanceId) : undefined)
4189
4130
  ?.adapter.interruptTurn(threadId)
4190
4131
  .catch(() => { });
@@ -4213,7 +4154,6 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4213
4154
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
4214
4155
  cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
4215
4156
  const results = await Promise.allSettled([
4216
- releaseBrowserCapabilityForThread(groupTurn.threadId),
4217
4157
  registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(groupTurn.threadId),
4218
4158
  ]);
4219
4159
  closeOpenApprovals(groupTurn.threadId);
@@ -4226,7 +4166,6 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
4226
4166
  const threadId = directClaim?.threadId ?? bot.threadId;
4227
4167
  revokeInternalCapabilitiesForThread(threadId);
4228
4168
  const results = await Promise.allSettled([
4229
- releaseBrowserCapabilityForThread(threadId),
4230
4169
  registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(threadId),
4231
4170
  ]);
4232
4171
  closeOpenApprovals(threadId);
@@ -4280,7 +4219,6 @@ routines = new RoutineManager({
4280
4219
  ? registry.get(bot.modelSelection.instanceId)
4281
4220
  : null;
4282
4221
  try {
4283
- await releaseBrowserCapabilityForThread(threadId);
4284
4222
  await instance?.adapter.interruptTurn(threadId);
4285
4223
  }
4286
4224
  finally {
@@ -4692,6 +4630,15 @@ setupRetry = 0) {
4692
4630
  return true;
4693
4631
  }
4694
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
+ };
4695
4642
  try {
4696
4643
  const integrations = {};
4697
4644
  const skillAuthoring = skillRecorderEnabled(cfg) &&
@@ -4841,19 +4788,7 @@ setupRetry = 0) {
4841
4788
  readyBot.browser !== false &&
4842
4789
  instance.adapter.capabilities.browserMcp === true) {
4843
4790
  const selectedProfile = readyBot.browserProfile;
4844
- const browser = await browserIntegration(readyBot.id, selectedProfile, threadId, internalGeneration, () => {
4845
- const currentBot = store.bot(readyBot.id);
4846
- const currentGroup = store.group(readyGroup.id);
4847
- const stillOwnsThread = currentGroup?.dm
4848
- ? currentGroup.threadId === threadId
4849
- : Boolean(currentGroup && store.groupTaskByThread(currentGroup.id, threadId));
4850
- return (!isCancelled?.() &&
4851
- stillOwnsThread &&
4852
- currentBot?.busy === true &&
4853
- builtInBrowserEnabled(cfg) &&
4854
- currentBot.browser !== false &&
4855
- currentBot.browserProfile === selectedProfile);
4856
- });
4791
+ const browser = browserIntegration(readyBot.id, selectedProfile);
4857
4792
  if (browser)
4858
4793
  integrations.browser = browser.integration;
4859
4794
  }
@@ -4862,7 +4797,6 @@ setupRetry = 0) {
4862
4797
  // room's setup claim so no provider turn starts after Stop returned.
4863
4798
  const browserReadyBot = store.bot(readyBot.id);
4864
4799
  if (isCancelled?.() || !browserReadyBot || !browserReadyBot.busy) {
4865
- await releaseBrowserCapabilityForThread(threadId);
4866
4800
  if (browserReadyBot?.busy) {
4867
4801
  store.setActivity(browserReadyBot.id, "idle");
4868
4802
  retryDelegationsWaitingOn(browserReadyBot.id);
@@ -4870,7 +4804,44 @@ setupRetry = 0) {
4870
4804
  return false;
4871
4805
  }
4872
4806
  store.patchGroup(readyGroup.id, { busyBotId: bot.id }); // the store's change stream carries the frame
4873
- 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
+ }
4874
4845
  const roster = readyGroup.memberIds
4875
4846
  .map((id) => store.bot(id))
4876
4847
  .filter((b) => Boolean(b))
@@ -4921,6 +4892,7 @@ setupRetry = 0) {
4921
4892
  store.patchBot(bot.id, { soulDrift: drift.drift });
4922
4893
  }
4923
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) },
4924
4896
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
4925
4897
  { id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
4926
4898
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
@@ -4946,7 +4918,6 @@ setupRetry = 0) {
4946
4918
  if (isCancelled?.() ||
4947
4919
  store.group(group.id)?.busyBotId !== bot.id ||
4948
4920
  store.bot(bot.id)?.busy !== true) {
4949
- await releaseBrowserCapabilityForThread(threadId);
4950
4921
  if (store.group(group.id)?.busyBotId === bot.id) {
4951
4922
  groupSpeakers.delete(threadId);
4952
4923
  store.patchGroup(group.id, { busyBotId: null, unread: true });
@@ -4978,7 +4949,6 @@ setupRetry = 0) {
4978
4949
  let unregisterStall = () => { };
4979
4950
  const deadline = new RoomTurnDeadline(timeoutMinutes, () => {
4980
4951
  abandonProviderTurn();
4981
- void releaseBrowserCapabilityForThread(threadId);
4982
4952
  void instance.adapter.interruptTurn(threadId).catch(() => { });
4983
4953
  store.appendMessage(threadId, {
4984
4954
  role: "bot",
@@ -5030,6 +5000,7 @@ setupRetry = 0) {
5030
5000
  });
5031
5001
  watchdog.watch(threadId, bot.id);
5032
5002
  onProviderHandshakeStarted?.();
5003
+ providerDispatched = true;
5033
5004
  guardTurnDispatch(instance.adapter.sendTurn({
5034
5005
  threadId,
5035
5006
  text,
@@ -5043,7 +5014,6 @@ setupRetry = 0) {
5043
5014
  // Stop may have landed while the adapter was authenticating, before
5044
5015
  // it had an active process for the first interrupt to reach. Now that
5045
5016
  // sendTurn completed setup, revoke again and interrupt the real turn.
5046
- await releaseBrowserCapabilityForThread(threadId);
5047
5017
  await instance.adapter.interruptTurn(threadId).catch(() => { });
5048
5018
  })
5049
5019
  .then((dispatch) => {
@@ -5083,6 +5053,9 @@ setupRetry = 0) {
5083
5053
  // work so a retained proxy from this member cannot act during the next
5084
5054
  // member's generation.
5085
5055
  revokeInternalCapabilityGeneration(threadId, internalGeneration);
5056
+ retainRoomVmLease = outcome === "timed_out" || outcome === "stalled";
5057
+ if (!retainRoomVmLease)
5058
+ releaseRoomVmLease();
5086
5059
  if (orchestration) {
5087
5060
  orchestration.result.replyText = replyText.trim();
5088
5061
  orchestration.result.outcome = outcome;
@@ -5122,6 +5095,7 @@ setupRetry = 0) {
5122
5095
  retry.unref?.();
5123
5096
  return;
5124
5097
  }
5098
+ releaseRoomVmLease();
5125
5099
  const currentGroup = store.group(group.id);
5126
5100
  const speaker = groupSpeakers.get(threadId);
5127
5101
  if (currentGroup?.busyBotId === bot.id && speaker?.botId === bot.id) {
@@ -5157,7 +5131,6 @@ setupRetry = 0) {
5157
5131
  if (outcome === "dispatch_failed") {
5158
5132
  if (skillAuthoring)
5159
5133
  skillAuthoringClaim.claimed = false;
5160
- await releaseBrowserCapabilityForThread(threadId);
5161
5134
  // No turn.completed follows a rejected room dispatch. Anything that was
5162
5135
  // queued while this bot briefly owned the room must be retried now.
5163
5136
  drainQueuedSends();
@@ -5217,10 +5190,38 @@ setupRetry = 0) {
5217
5190
  }
5218
5191
  return true;
5219
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
+ }
5220
5207
  finally {
5221
5208
  // Covers connector/setup failures, cancellation before dispatch, and all
5222
5209
  // other early returns that never produce a provider terminal event.
5223
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
+ }
5224
5225
  }
5225
5226
  }
5226
5227
  async function runGroupGoalStep(args) {
@@ -6455,13 +6456,15 @@ async function localVmPayload(target) {
6455
6456
  * instance cap; creating past it would quietly do what the lifecycle route
6456
6457
  * refuses.
6457
6458
  */
6458
- async function readyLocalVmForTurn(botId, target) {
6459
+ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
6459
6460
  let status = await containerComputerStatus(undefined, undefined, target);
6461
+ if (!isCurrent())
6462
+ return status;
6460
6463
  if (status.ready || !localVmRecreatableOnDemand(status))
6461
6464
  return status;
6462
6465
  if (target.key !== SHARED_LOCAL_VM_TARGET.key) {
6463
6466
  const count = await existingPerBotLocalVmCount(status.runtime);
6464
- if (count >= localVmMaxInstances(cfg))
6467
+ if (!isCurrent() || count >= localVmMaxInstances(cfg))
6465
6468
  return status;
6466
6469
  }
6467
6470
  broadcast({ kind: "computer", botId, state: "provisioning" });
@@ -6484,8 +6487,10 @@ async function readyLocalVmForTurn(botId, target) {
6484
6487
  // the turn is the whole point: a person who has been away eight hours should
6485
6488
  // not have to send their message twice.
6486
6489
  const deadline = Date.now() + LOCAL_VM_DESKTOP_WAIT_MS;
6487
- while (!status.ready && status.container === "running" && Date.now() < deadline) {
6490
+ while (isCurrent() && !status.ready && status.container === "running" && Date.now() < deadline) {
6488
6491
  await new Promise((resolve) => setTimeout(resolve, 2_000));
6492
+ if (!isCurrent())
6493
+ break;
6489
6494
  status = await containerComputerStatus(undefined, undefined, target);
6490
6495
  }
6491
6496
  return status;
@@ -6534,6 +6539,9 @@ function configStatus() {
6534
6539
  showToolCalls: showToolCallsEnabled(cfg),
6535
6540
  browser: builtInBrowserEnabled(cfg),
6536
6541
  },
6542
+ // Which browser this server can give bots: the desktop app's surface,
6543
+ // the agent-browser engine, or nothing yet (with the reason).
6544
+ browserEngine: browserEngineSummary(),
6537
6545
  // partitionId is non-secret routing metadata. The renderer needs it to
6538
6546
  // show the same durable session as an agent, but config PATCH validation
6539
6547
  // keeps it read-only and rejects callers that try to choose it.
@@ -6558,7 +6566,6 @@ async function reloadProviders() {
6558
6566
  // one synchronous step before the first teardown await, including room/task
6559
6567
  // threads that are not a bot's default DM.
6560
6568
  revokeAllInternalCapabilities();
6561
- await releaseAllBrowserCapabilities();
6562
6569
  bus.detachAll();
6563
6570
  await registry.disposeAll();
6564
6571
  await registry.load(instanceConfigs(cfg));
@@ -9216,7 +9223,6 @@ const handleRequest = async (req, res) => {
9216
9223
  cancelGroupTurnOperations(group.id, threadId);
9217
9224
  for (const { threadId, instance } of interruptTargets) {
9218
9225
  revokeInternalCapabilitiesForThread(threadId);
9219
- await releaseBrowserCapabilityForThread(threadId);
9220
9226
  await instance?.adapter.interruptTurn(threadId).catch(() => { });
9221
9227
  closeOpenApprovals(threadId);
9222
9228
  }
@@ -9814,7 +9820,6 @@ const handleRequest = async (req, res) => {
9814
9820
  cancelDirectTurnDispatch(bot.id, routineRun.threadId);
9815
9821
  if (routineRun.threadId) {
9816
9822
  revokeInternalCapabilitiesForThread(routineRun.threadId);
9817
- await releaseBrowserCapabilityForThread(routineRun.threadId);
9818
9823
  }
9819
9824
  await routines.cancelRun(routineRun.id);
9820
9825
  return;
@@ -9824,7 +9829,6 @@ const handleRequest = async (req, res) => {
9824
9829
  if (groupTurn) {
9825
9830
  cancelGroupTurnOperations(groupTurn.group.id, groupTurn.threadId);
9826
9831
  revokeInternalCapabilitiesForThread(groupTurn.threadId);
9827
- await releaseBrowserCapabilityForThread(groupTurn.threadId);
9828
9832
  await instance?.adapter.interruptTurn(groupTurn.threadId).catch(() => { });
9829
9833
  closeOpenApprovals(groupTurn.threadId);
9830
9834
  return;
@@ -9832,7 +9836,6 @@ const handleRequest = async (req, res) => {
9832
9836
  const directClaim = cancelDirectTurnDispatch(bot.id);
9833
9837
  const threadId = directClaim?.threadId ?? bot.threadId;
9834
9838
  revokeInternalCapabilitiesForThread(threadId);
9835
- await releaseBrowserCapabilityForThread(threadId);
9836
9839
  await instance?.adapter.interruptTurn(threadId).catch(() => { });
9837
9840
  closeOpenApprovals(threadId);
9838
9841
  }));
@@ -9959,7 +9962,7 @@ const handleRequest = async (req, res) => {
9959
9962
  // or unreadable journal therefore rejects the delete with the bot and
9960
9963
  // all of its live work untouched. The intent is aborted if a later
9961
9964
  // pre-delete side effect fails, and committed only after Store deletion.
9962
- const browserCleanupRequest = utilityParentPort ? browserCleanup.prepare("bot", bot.id) : null;
9965
+ const browserCleanupRequest = browserCleanup.prepare("bot", bot.id);
9963
9966
  try {
9964
9967
  // a running turn dies with its bot
9965
9968
  const directClaim = cancelDirectTurnDispatch(bot.id);
@@ -9969,7 +9972,6 @@ const handleRequest = async (req, res) => {
9969
9972
  // check is revalidated after its body arrives and must fail closed.
9970
9973
  revokeInternalCapabilitiesForThread(directThreadId);
9971
9974
  directTurnGenerationByBot.delete(bot.id);
9972
- await releaseBrowserCapabilitiesForBot(bot.id);
9973
9975
  await registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(directThreadId).catch(() => { });
9974
9976
  closeOpenApprovals(directThreadId);
9975
9977
  // Deletion removes the thread before a late turn.completed can fold
@@ -10730,7 +10732,6 @@ const handleRequest = async (req, res) => {
10730
10732
  cancelDirectTurnDispatch(bot.id, routineRun.threadId ?? expectedThreadId);
10731
10733
  if (routineRun.threadId) {
10732
10734
  revokeInternalCapabilitiesForThread(routineRun.threadId);
10733
- await releaseBrowserCapabilityForThread(routineRun.threadId);
10734
10735
  }
10735
10736
  await routines.cancelRun(routineRun.id);
10736
10737
  return json(res, 200, { ok: true });
@@ -10745,7 +10746,6 @@ const handleRequest = async (req, res) => {
10745
10746
  }
10746
10747
  cancelGroupTurnOperations(busyGroup.group.id, busyGroup.threadId);
10747
10748
  revokeInternalCapabilitiesForThread(busyGroup.threadId);
10748
- await releaseBrowserCapabilityForThread(busyGroup.threadId);
10749
10749
  await instance?.adapter.interruptTurn(busyGroup.threadId).catch(() => { });
10750
10750
  closeOpenApprovals(busyGroup.threadId);
10751
10751
  return json(res, 200, { ok: true });
@@ -10759,7 +10759,6 @@ const handleRequest = async (req, res) => {
10759
10759
  const cancelledDirect = cancelDirectTurnDispatch(bot.id, expectedThreadId);
10760
10760
  const directThreadId = cancelledDirect?.threadId ?? bot.threadId;
10761
10761
  revokeInternalCapabilitiesForThread(directThreadId);
10762
- await releaseBrowserCapabilityForThread(directThreadId);
10763
10762
  await instance?.adapter.interruptTurn(directThreadId).catch(() => { });
10764
10763
  closeOpenApprovals(directThreadId);
10765
10764
  return json(res, 200, { ok: true });
@@ -11047,6 +11046,26 @@ const handleRequest = async (req, res) => {
11047
11046
  if (method === "GET" && path === "/api/health") {
11048
11047
  return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR) });
11049
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
+ }
11050
11069
  // Which edition this server runs and why (see server/enterprise.ts). Read-only.
11051
11070
  if (method === "GET" && path === "/api/edition") {
11052
11071
  return json(res, 200, editionStatus());
@@ -11608,13 +11627,11 @@ const handleRequest = async (req, res) => {
11608
11627
  }
11609
11628
  const browserCleanupRequests = [];
11610
11629
  try {
11611
- if (utilityParentPort) {
11612
- for (const profileId of removedBrowserProfileIds) {
11613
- const target = browserProfilePartitionTarget(cfg, profileId);
11614
- if (!target)
11615
- throw new Error(`browser profile cleanup target “${profileId}” is unavailable`);
11616
- browserCleanupRequests.push(browserCleanup.prepare("profile", target.profileId, target.partitionId));
11617
- }
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));
11618
11635
  }
11619
11636
  }
11620
11637
  catch (error) {
@@ -11722,7 +11739,6 @@ const handleRequest = async (req, res) => {
11722
11739
  let mandatoryError = null;
11723
11740
  if (disablingBuiltInBrowser) {
11724
11741
  try {
11725
- await releaseAllBrowserCapabilities();
11726
11742
  }
11727
11743
  catch (error) {
11728
11744
  mandatoryError = error;
@@ -12200,7 +12216,6 @@ const gracefulShutdown = createGracefulShutdown({
12200
12216
  webhookIngress?.server.close();
12201
12217
  tunnelListener?.close();
12202
12218
  },
12203
- () => releaseAllBrowserCapabilities(),
12204
12219
  () => registry.disposeAll(),
12205
12220
  () => flushAllProfileHistory(),
12206
12221
  ],