openmausbot 0.1.65 → 0.1.66

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.
@@ -14,11 +14,12 @@ import { CREDENTIAL_TARGETS, credentialResumeOutcome, credentialIsConfigured, is
14
14
  import { HELD_NOTE, approvalHeldNote, approvalHeldReason, approvalModeForOrigin, autoVerdict, rememberableApprovalKey } from "./auto-approve.js";
15
15
  import { requestReview, resolveAutoReviewMode, shouldReview } from "./auto-review.js";
16
16
  import { updateClaudeCli } from "./claude-update.js";
17
+ import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
17
18
  import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
18
19
  import * as checkpoints from "./checkpoints.js";
19
20
  import { appendDecision, readDecisions } from "./decision-log.js";
20
21
  import { validateBotCwd } from "./bot-cwd.js";
21
- import { ATTACHMENTS_DIR, attachmentExists, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
22
+ import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
22
23
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
23
24
  import { avatarGenerationRequestSchema, avatarGenerationStateMatches, generateAvatarImage, snapshotAvatarGenerationState, } from "./avatar-image.js";
24
25
  import { fitsOnOneLine, parseBotProfilePatch } from "./bot-profile.js";
@@ -32,7 +33,7 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
32
33
  import { peerAllowed, peerName, peerRosterSystemPrompt, reachablePeers, roomPeerRosterSystemPrompt, roomRosterLine } from "./peer-roster.js";
33
34
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
34
35
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
35
- import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
36
+ import { ensureDirs, instanceConfigs, loadConfig, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, saveConfig, showToolCallsEnabled, skillRecorderEnabled, builtInBrowserEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, } from "./config.js";
36
37
  import { ComputerControl } from "./computer-control.js";
37
38
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
38
39
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -87,7 +88,9 @@ import * as vps from "./vps-computer.js";
87
88
  import { RoutineManager } from "./routines.js";
88
89
  import { CalendarCallManager } from "./calendar-calls.js";
89
90
  import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
90
- import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
91
+ import { BrowserRuntime } from "./browser-runtime.js";
92
+ import { BrowserLive } from "./browser-live.js";
93
+ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
91
94
  import { createScreenFrameSource } from "./screen-frame-source.js";
92
95
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
93
96
  import { RoutineRequestService } from "./routine-requests.js";
@@ -117,7 +120,7 @@ import { createGracefulShutdown } from "./graceful-shutdown.js";
117
120
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
118
121
  import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
119
122
  import { environmentDescriptor, loadEnvironmentId } from "./environment.js";
120
- import { createCustomDomainVerifier, normalizeCustomDomain } from "./custom-domain.js";
123
+ import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
121
124
  import { ProviderAuthSessions } from "./provider-auth-sessions.js";
122
125
  import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
123
126
  import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
@@ -191,6 +194,7 @@ function customDomainStatus() {
191
194
  return {
192
195
  customDomain: savedCustomDomain(), publicUrl: publicUrl(), fallbackUrl: FALLBACK_PUBLIC_URL,
193
196
  supported: !DESKTOP_MANAGED, appPort: PORT, webhookPort: WEBHOOK_PORT,
197
+ serverIpv4: DESKTOP_MANAGED ? null : customDomainIpv4(),
194
198
  };
195
199
  }
196
200
  const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
@@ -248,7 +252,7 @@ const browserCleanup = new BrowserCleanupCoordinator({
248
252
  ? Promise.all(sessions.map(async (session) => {
249
253
  const ok = await clearBrowserSessionState(status.binaryPath, session, { encryptionKey: browserEngineEncryptionKey() });
250
254
  if (!ok)
251
- 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\``);
255
+ console.warn(`browser cleanup: could not clear saved state for session ${session}; restart OpenMausBot to retry this profile's cleanup. Do not use state clear --all: it erases other profiles too.`);
252
256
  return ok;
253
257
  }))
254
258
  : Promise.resolve([true]);
@@ -443,10 +447,7 @@ function purgeGeneratedImagesForThread(threadId) {
443
447
  continue;
444
448
  generatedImagesByTurn.delete(key);
445
449
  for (const attachment of attachments) {
446
- try {
447
- unlinkSync(attachment.path);
448
- }
449
- catch { }
450
+ deleteAttachment(attachment.path);
450
451
  }
451
452
  }
452
453
  }
@@ -466,10 +467,7 @@ function retireProviderTurn(turnId) {
466
467
  continue;
467
468
  generatedImagesByTurn.delete(key);
468
469
  for (const attachment of attachments) {
469
- try {
470
- unlinkSync(attachment.path);
471
- }
472
- catch { }
470
+ deleteAttachment(attachment.path);
473
471
  }
474
472
  }
475
473
  }
@@ -524,7 +522,40 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
524
522
  /** The bot's browser for this turn: agent-browser, one isolated session per
525
523
  * browser profile or per bot (docs/plans/browser-engine.md). Null, with the
526
524
  * reason logged once, when the engine is not on this machine. */
527
- function browserIntegration(botId, profile) {
525
+ const browserRuntime = new BrowserRuntime();
526
+ const browserLive = new BrowserLive({ runtime: browserRuntime });
527
+ // Temporary profiles last for this server run, but are never saved to disk.
528
+ // The viewer and the agent must address the SAME temporary browser.
529
+ const temporaryBrowserSessions = new Map();
530
+ function currentBrowserSession(botId, profile) {
531
+ if (profile === "guest") {
532
+ let session = temporaryBrowserSessions.get(botId);
533
+ if (!session) {
534
+ session = browserSessionId(botId, "guest");
535
+ temporaryBrowserSessions.set(botId, session);
536
+ }
537
+ return session;
538
+ }
539
+ const target = profile ? browserProfilePartitionTarget(cfg, profile) : null;
540
+ return browserSessionId(botId, target?.partitionId ?? "");
541
+ }
542
+ async function forgetTemporaryBrowser(botId) {
543
+ const session = temporaryBrowserSessions.get(botId);
544
+ if (!session)
545
+ return;
546
+ temporaryBrowserSessions.delete(botId);
547
+ const engine = browserEngineStatus();
548
+ if (engine.kind !== "ready")
549
+ return;
550
+ const closed = await clearBrowserSessionState(engine.binaryPath, session, {
551
+ env: { PATH: augmentedPath() }, encryptionKey: browserEngineEncryptionKey(),
552
+ });
553
+ if (closed)
554
+ await browserRuntime.close(session);
555
+ else
556
+ console.warn(`temporary browser ${session}: could not close its session; run agent-browser --session ${session} close on this server`);
557
+ }
558
+ async function browserIntegration(botId, profile, turn) {
528
559
  const status = browserEngineStatus();
529
560
  if (status.kind !== "ready") {
530
561
  if (!engineUnavailableLogged) {
@@ -536,17 +567,29 @@ function browserIntegration(botId, profile) {
536
567
  // A profile that no longer exists falls back to the bot's own session.
537
568
  const profileTarget = profile && profile !== "guest" ? browserProfilePartitionTarget(cfg, profile) : null;
538
569
  const partitionId = profile === "guest" ? "guest" : (profileTarget?.partitionId ?? "");
539
- const session = browserSessionId(botId, partitionId);
540
- return {
541
- profile: partitionId,
542
- integration: agentBrowserIntegration({
543
- binaryPath: status.binaryPath,
544
- session,
545
- encryptionKey: browserEngineEncryptionKey(),
546
- persistent: profile !== "guest",
547
- env: { ...process.env, PATH: augmentedPath() },
548
- }),
549
- };
570
+ const session = currentBrowserSession(botId, profile);
571
+ const spec = agentBrowserIntegration({
572
+ binaryPath: status.binaryPath,
573
+ session,
574
+ encryptionKey: browserEngineEncryptionKey(),
575
+ persistent: profile !== "guest",
576
+ env: { ...process.env, PATH: augmentedPath() },
577
+ });
578
+ await prepareBrowserSessionState(status.binaryPath, session, { env: spec.env, persistent: profile !== "guest", isCurrent: () => {
579
+ const current = store.bot(botId);
580
+ return !!current && current.browser !== false && builtInBrowserEnabled(cfg)
581
+ && currentBrowserSession(current.id, current.browserProfile) === session
582
+ && (!turn || activeInternalGenerationByThread.get(turn.threadId) === turn.generation);
583
+ } });
584
+ if (!turn)
585
+ return { profile: partitionId, session, spec, integration: spec };
586
+ const token = mintInternalCapability({ botId, ...turn, browserSession: session,
587
+ kind: "browser", depth: 0, skillAuthoring: false, createdBots: 0 });
588
+ return { profile: partitionId, session, spec, integration: {
589
+ command: process.execPath, args: [SPAWNED_PROXIES.browser], env: {
590
+ ...AGENTS_NODE_FLAG, OMB_BROWSER_TOKEN: token, OMB_HARNESS_URL: `http://127.0.0.1:${PORT}`,
591
+ },
592
+ } };
550
593
  }
551
594
  let engineUnavailableLogged = false;
552
595
  let browserEngineInstall = null;
@@ -718,6 +761,9 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
718
761
  return { ok: false, status: 409, error: "the bot is working — stop it before changing models" };
719
762
  }
720
763
  const target = registry.get(selection.instanceId);
764
+ if (providerInstancesChanging.has(selection.instanceId)) {
765
+ return { ok: false, status: 409, error: "this provider account is being updated — try again shortly" };
766
+ }
721
767
  // Model IDs remain free-form at the app's general API boundary. Custom
722
768
  // engines can accept IDs that are not in their discovery catalog, and
723
769
  // several drivers only learn the final catalog when a turn starts. The
@@ -3434,6 +3480,9 @@ async function startTurn(botId, text, opts) {
3434
3480
  }
3435
3481
  console.error(`[omb-turn] bot=${botId} text=${JSON.stringify(resolvedImages.text.slice(0, 70))} images=${turnImages.length} depth=${commsDepth} card=${Boolean(opts?.cardContinuation)}`);
3436
3482
  const instanceId = instance.instanceId;
3483
+ if (providerInstancesChanging.has(instanceId)) {
3484
+ throw Object.assign(new Error("this provider account is being updated — try again shortly"), { status: 409 });
3485
+ }
3437
3486
  const model = opts?.runOn === "cloud" ? instance.models.default : bot.modelSelection.model;
3438
3487
  // a cloud routine borrows the instance default model, so it borrows no
3439
3488
  // per-bot effort either
@@ -3877,7 +3926,7 @@ async function startTurn(botId, text, opts) {
3877
3926
  liveBot.browser !== false &&
3878
3927
  instance.adapter.capabilities.browserMcp === true) {
3879
3928
  const selectedProfile = liveBot.browserProfile;
3880
- browser = browserIntegration(bot.id, selectedProfile);
3929
+ browser = await browserIntegration(bot.id, selectedProfile, { threadId, generation: dispatchClaimId });
3881
3930
  if (browser)
3882
3931
  integrations.browser = browser.integration;
3883
3932
  // The browser lost its frame source when the Electron surface was
@@ -3887,8 +3936,9 @@ async function startTurn(botId, text, opts) {
3887
3936
  // the work was a web page, because agent-browser runs its own headless
3888
3937
  // Chrome on the host rather than inside that desktop.
3889
3938
  if (browser) {
3890
- const frame = { binaryPath: browser.integration.command, env: browser.integration.env };
3891
- browserCapture = () => agentBrowserFrame(frame);
3939
+ const frame = { binaryPath: browser.spec.command, env: browser.spec.env };
3940
+ const session = browser.session;
3941
+ browserCapture = () => browserRuntime.withAgentAction(session, () => agentBrowserFrame(frame));
3892
3942
  }
3893
3943
  }
3894
3944
  // A cancelled adapter can be between accepting sendTurn and revealing
@@ -4619,6 +4669,10 @@ setupRetry = 0) {
4619
4669
  const preparedComposio = bot.composio;
4620
4670
  const instance = registry.get(bot.modelSelection.instanceId);
4621
4671
  const userName = cfg.profile?.name?.trim() || "User";
4672
+ if (providerInstancesChanging.has(bot.modelSelection.instanceId)) {
4673
+ onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
4674
+ return true;
4675
+ }
4622
4676
  if (!instance) {
4623
4677
  const message = `${bot.name}'s model is unavailable`;
4624
4678
  store.appendMessage(threadId, {
@@ -4801,6 +4855,11 @@ setupRetry = 0) {
4801
4855
  return true;
4802
4856
  }
4803
4857
  store.setActivity(bot.id, "working");
4858
+ // Claim cleanup ownership before browser preparation can yield or reject.
4859
+ // The finally block must release exactly this setup, never a newer turn.
4860
+ roomSpeaker = { botId: bot.id, name: bot.name, color: bot.color };
4861
+ groupSpeakers.set(threadId, roomSpeaker);
4862
+ store.patchGroup(readyGroup.id, { busyBotId: bot.id });
4804
4863
  orchestration?.onClaimed?.();
4805
4864
  // Connected-app discovery above can yield for a network round trip. A
4806
4865
  // profile may be removed, or the browser feature switched off, during that
@@ -4811,24 +4870,19 @@ setupRetry = 0) {
4811
4870
  readyBot.browser !== false &&
4812
4871
  instance.adapter.capabilities.browserMcp === true) {
4813
4872
  const selectedProfile = readyBot.browserProfile;
4814
- const browser = browserIntegration(readyBot.id, selectedProfile);
4873
+ const browser = await browserIntegration(readyBot.id, selectedProfile, { threadId, generation: internalGeneration });
4815
4874
  if (browser)
4816
4875
  integrations.browser = browser.integration;
4817
4876
  }
4818
- // Stop/delete may land while Electron is registering the capability. The
4819
- // callback above prevents publication; this second check also unwinds the
4820
- // room's setup claim so no provider turn starts after Stop returned.
4877
+ // Stop/delete may land while browser state is being prepared. Capability
4878
+ // publication and this exact claim are both fenced; finally releases only
4879
+ // this setup, so a replacement turn's busy state is never cleared here.
4821
4880
  const browserReadyBot = store.bot(readyBot.id);
4822
- if (isCancelled?.() || !browserReadyBot || !browserReadyBot.busy) {
4823
- if (browserReadyBot?.busy) {
4824
- store.setActivity(browserReadyBot.id, "idle");
4825
- retryDelegationsWaitingOn(browserReadyBot.id);
4826
- }
4881
+ if (isCancelled?.() || !browserReadyBot?.busy ||
4882
+ groupSpeakers.get(threadId) !== roomSpeaker ||
4883
+ activeInternalGenerationByThread.get(threadId) !== internalGeneration) {
4827
4884
  return false;
4828
4885
  }
4829
- store.patchGroup(readyGroup.id, { busyBotId: bot.id }); // the store's change stream carries the frame
4830
- roomSpeaker = { botId: bot.id, name: bot.name, color: bot.color };
4831
- groupSpeakers.set(threadId, roomSpeaker);
4832
4886
  // Room and Goal turns use the speaker's desktop, never the coordinator's.
4833
4887
  // Claim the same lease as direct turns before asynchronous VM setup.
4834
4888
  if (readyBot.computer === "vm") {
@@ -6594,6 +6648,41 @@ function persistMcpServers(next) {
6594
6648
  // when an empty section is omitted by an older config file.
6595
6649
  cfg.mcpServers = next;
6596
6650
  }
6651
+ async function describeInstances() {
6652
+ const configs = instanceConfigs(cfg);
6653
+ return (await registry.describe()).map((instance) => {
6654
+ const entry = configs[instance.instanceId];
6655
+ if (entry?.driver !== "claudeAgent")
6656
+ return instance;
6657
+ try {
6658
+ const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
6659
+ return { ...instance, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
6660
+ }
6661
+ catch {
6662
+ // A malformed saved config remains a repairable shadow, never takes
6663
+ // the model picker down or offers a login for the wrong directory.
6664
+ return { ...instance, install: { ...instance.install, signInCommand: undefined } };
6665
+ }
6666
+ });
6667
+ }
6668
+ async function persistProviderInstance(instanceId, instances) {
6669
+ saveConfig({ instances }, { replaceInstances: true });
6670
+ cfg.instances = instances;
6671
+ providerAuthSessions.clearInstance(instanceId);
6672
+ bus.detach(instanceId);
6673
+ // No whole-fleet reload: other bots keep their live CLI processes, event
6674
+ // subscriptions and approval capabilities while this one is replaced.
6675
+ if (Object.hasOwn(instances, instanceId)) {
6676
+ await registry.load({ [instanceId]: instanceConfigs(cfg)[instanceId] });
6677
+ const live = registry.get(instanceId);
6678
+ if (live)
6679
+ bus.attach([live]);
6680
+ }
6681
+ else {
6682
+ await registry.dispose(instanceId);
6683
+ }
6684
+ resetPathCache();
6685
+ }
6597
6686
  /** Rebuild the provider fleet after a config change so new keys take
6598
6687
  * effect without a server restart (kills any in-flight turns). */
6599
6688
  async function reloadProviders() {
@@ -6634,6 +6723,7 @@ async function reloadProviders() {
6634
6723
  // and reload sequence single-flight so two settings requests cannot drop one
6635
6724
  // another's changes or dispose a fleet while another reload is creating it.
6636
6725
  let providerConfigBusy = false;
6726
+ const providerInstancesChanging = new Set();
6637
6727
  let mcpConfigBusy = false;
6638
6728
  const MAX_CONCURRENT_MCP_PROBES = 2;
6639
6729
  let mcpProbesInFlight = 0;
@@ -6944,11 +7034,13 @@ const handleRequest = async (req, res) => {
6944
7034
  if (!internalSender) {
6945
7035
  return json(res, 401, { error: "unauthorized" });
6946
7036
  }
6947
- const requiredCapabilityKind = path.startsWith("/api/internal/connectors/")
6948
- ? "connectors"
6949
- : path === "/api/internal/computer-control"
6950
- ? "computer"
6951
- : "agents";
7037
+ const requiredCapabilityKind = path === "/api/internal/browser/mcp"
7038
+ ? "browser"
7039
+ : path.startsWith("/api/internal/connectors/")
7040
+ ? "connectors"
7041
+ : path === "/api/internal/computer-control"
7042
+ ? "computer"
7043
+ : "agents";
6952
7044
  if (internalCapability.kind !== requiredCapabilityKind) {
6953
7045
  return json(res, 403, { error: "this internal capability cannot access that service" });
6954
7046
  }
@@ -6992,6 +7084,30 @@ const handleRequest = async (req, res) => {
6992
7084
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
6993
7085
  }
6994
7086
  };
7087
+ if (method === "POST" && path === "/api/internal/browser/mcp") {
7088
+ const body = await readInternalBody();
7089
+ const bot = store.bot(internalCapability.botId);
7090
+ if (!bot || bot.browser === false || !builtInBrowserEnabled(cfg)) {
7091
+ return json(res, 403, { error: "browser tools are not enabled for this bot" });
7092
+ }
7093
+ const browser = await browserIntegration(bot.id, bot.browserProfile);
7094
+ if (!browser || browser.session !== internalCapability.browserSession) {
7095
+ return json(res, 409, { error: "this browser profile changed; start a new turn" });
7096
+ }
7097
+ if (body?.method !== "tools/list" && body?.method !== "tools/call") {
7098
+ return json(res, 400, { error: "unsupported browser method" });
7099
+ }
7100
+ const result = await browserRuntime.agentRpc(browser.session, browser.spec, body.method, body.params, () => {
7101
+ requireActiveInternalCapability();
7102
+ const current = store.bot(bot.id);
7103
+ if (!current || current.browser === false || !builtInBrowserEnabled(cfg) ||
7104
+ currentBrowserSession(current.id, current.browserProfile) !== browser.session) {
7105
+ throw Object.assign(new Error("Browser access changed while connecting."), { status: 409 });
7106
+ }
7107
+ });
7108
+ requireActiveInternalCapability();
7109
+ return json(res, 200, { result });
7110
+ }
6995
7111
  if (method === "GET" && path === "/api/internal/agents") {
6996
7112
  const sender = internalSender;
6997
7113
  // title/description included so the caller can judge the team (who
@@ -8084,6 +8200,45 @@ const handleRequest = async (req, res) => {
8084
8200
  : json(res, 404, { error: "no such webhook" });
8085
8201
  }
8086
8202
  // ── events stream ──
8203
+ // Owner-only (default-deny in request-auth). Never mix login frames into
8204
+ // the general events feed, which is also visible to client-only devices.
8205
+ const liveBrowserMatch = /^\/api\/bots\/([\w-]+)\/browser\/(live|action)$/.exec(path);
8206
+ if (liveBrowserMatch) {
8207
+ res.setHeader("cache-control", "no-store");
8208
+ const bot = store.bot(liveBrowserMatch[1]);
8209
+ if (!bot)
8210
+ return json(res, 404, { error: "no such bot" });
8211
+ if (!builtInBrowserEnabled(cfg) || bot.browser === false) {
8212
+ return json(res, 409, { error: "Enable this bot's browser in its profile first." });
8213
+ }
8214
+ const browser = await browserIntegration(bot.id, bot.browserProfile);
8215
+ if (!browser)
8216
+ return json(res, 503, { error: "Install the browser engine first." });
8217
+ const owner = auth.kind === "session" ? auth.session.id : "local-owner";
8218
+ const isCurrent = () => {
8219
+ const current = store.bot(bot.id);
8220
+ return !!current && current.browser !== false && builtInBrowserEnabled(cfg)
8221
+ && currentBrowserSession(current.id, current.browserProfile) === browser.session
8222
+ && (auth.kind !== "session" || sessions.isLive(auth.session.id));
8223
+ };
8224
+ if (method === "GET" && liveBrowserMatch[2] === "live") {
8225
+ req.socket.setTimeout(0);
8226
+ return await browserLive.open({ botId: bot.id, session: browser.session, spec: browser.spec, owner, isCurrent, res });
8227
+ }
8228
+ if (method === "POST" && liveBrowserMatch[2] === "action") {
8229
+ const body = await readBody(req, 32_768);
8230
+ if (!isCurrent())
8231
+ return json(res, 409, { error: "This browser session changed. Reopen the browser panel." });
8232
+ if (typeof body?.viewerId !== "string")
8233
+ return json(res, 400, { error: "A live browser connection is required." });
8234
+ if (body.type === "restart" && store.bots.some((candidate) => candidate.busy &&
8235
+ currentBrowserSession(candidate.id, candidate.browserProfile) === browser.session)) {
8236
+ return json(res, 409, { error: "Stop every bot using this profile before restarting its browser." });
8237
+ }
8238
+ return json(res, 200, await browserLive.action({ viewerId: body.viewerId, botId: bot.id, owner, body }));
8239
+ }
8240
+ return json(res, 405, { error: "method not allowed" });
8241
+ }
8087
8242
  if (method === "GET" && path === "/api/events") {
8088
8243
  const client = {
8089
8244
  res,
@@ -9472,10 +9627,7 @@ const handleRequest = async (req, res) => {
9472
9627
  if (!bot) {
9473
9628
  // There are no awaits between the refreshed lookup and this patch, but
9474
9629
  // keep the attachment invariant explicit if the store ever changes.
9475
- try {
9476
- unlinkSync(saved.path);
9477
- }
9478
- catch { }
9630
+ deleteAttachment(saved.path);
9479
9631
  return json(res, 404, { error: "no such bot" });
9480
9632
  }
9481
9633
  const visible = wireBot(bot);
@@ -9581,6 +9733,8 @@ const handleRequest = async (req, res) => {
9581
9733
  if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
9582
9734
  return json(res, 400, { error: "requireAvailableModel must be true or false" });
9583
9735
  }
9736
+ const beforeBrowserProfile = existingBot?.browserProfile;
9737
+ const beforeBrowserEnabled = existingBot?.browser;
9584
9738
  // Neither Codex (free-form string field) nor Grok (lazy, logs-only)
9585
9739
  // rejects an unknown effort level at their own boundary — this is the
9586
9740
  // only real gate, so it stays. But it fires only when the target
@@ -9697,6 +9851,10 @@ const handleRequest = async (req, res) => {
9697
9851
  if (existingBot?.busy && requestedProfile !== existingBot.browserProfile) {
9698
9852
  return json(res, 409, { error: "stop this bot's turn before changing its browser profile" });
9699
9853
  }
9854
+ if (existingBot && requestedProfile !== existingBot.browserProfile &&
9855
+ browserRuntime.heldBy(currentBrowserSession(existingBot.id, existingBot.browserProfile))) {
9856
+ return json(res, 409, { error: "Release browser control before changing its profile." });
9857
+ }
9700
9858
  if (requestedProfile === undefined)
9701
9859
  patch.browserProfile = undefined;
9702
9860
  else if (typeof requestedProfile === "string" &&
@@ -9909,6 +10067,12 @@ const handleRequest = async (req, res) => {
9909
10067
  section !== undefined &&
9910
10068
  sectionKey(existingBot?.section) !== sectionKey(section);
9911
10069
  let bot;
10070
+ const freshBrowserBot = store.bot(m[1]);
10071
+ if (freshBrowserBot && body.browserProfile !== undefined &&
10072
+ patch.browserProfile !== freshBrowserBot.browserProfile &&
10073
+ (freshBrowserBot.busy || browserRuntime.heldBy(currentBrowserSession(freshBrowserBot.id, freshBrowserBot.browserProfile)))) {
10074
+ return json(res, 409, { error: "Stop the bot and release browser control before changing its profile." });
10075
+ }
9912
10076
  if (profile.patch.soul !== undefined) {
9913
10077
  // A mixed settings request must not turn a runtime revocation into
9914
10078
  // a persist-first edit. Apply runtime fields with their existing
@@ -9925,6 +10089,12 @@ const handleRequest = async (req, res) => {
9925
10089
  }
9926
10090
  if (!bot)
9927
10091
  return json(res, 404, { error: "no such bot" });
10092
+ if (existingBot && (bot.browserProfile !== beforeBrowserProfile || bot.browser !== beforeBrowserEnabled)) {
10093
+ browserLive.closeForBot(bot.id);
10094
+ if (beforeBrowserProfile === "guest" && (bot.browserProfile !== "guest" || bot.browser === false)) {
10095
+ void forgetTemporaryBrowser(bot.id).catch((error) => console.warn("temporary browser cleanup failed", error));
10096
+ }
10097
+ }
9928
10098
  const chiefChanges = body.chiefOfStaff === true || chiefMovedSections
9929
10099
  ? store.setChiefOfStaff(bot.id)
9930
10100
  : [];
@@ -10121,6 +10291,8 @@ const handleRequest = async (req, res) => {
10121
10291
  localVmIdles.get(target.key)?.cancel();
10122
10292
  localVmIdles.delete(target.key);
10123
10293
  store.deleteBot(bot.id);
10294
+ browserLive.closeForBot(bot.id);
10295
+ await forgetTemporaryBrowser(bot.id);
10124
10296
  }
10125
10297
  catch (error) {
10126
10298
  if (browserCleanupRequest)
@@ -11239,7 +11411,26 @@ const handleRequest = async (req, res) => {
11239
11411
  // Windows never pushes PATH changes into a live process, so without
11240
11412
  // this the answer is frozen at boot and "check again" is a no-op.
11241
11413
  resetPathCache();
11242
- return json(res, 200, { instances: await registry.describe() });
11414
+ return json(res, 200, { instances: await describeInstances() });
11415
+ }
11416
+ if (method === "POST" && path === "/api/instances/claude-accounts") {
11417
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
11418
+ return json(res, 415, { error: "content-type must be application/json" });
11419
+ }
11420
+ const parsed = createClaudeAccountSchema.safeParse(await readBody(req, 8192));
11421
+ if (!parsed.success)
11422
+ return json(res, 400, { error: "Enter an account name (up to 80 characters) and an optional configuration directory." });
11423
+ if (providerConfigBusy)
11424
+ return json(res, 409, { error: "provider settings are already being updated" });
11425
+ providerConfigBusy = true;
11426
+ try {
11427
+ const { instanceId, instances } = newClaudeAccount(cfg, parsed.data);
11428
+ await persistProviderInstance(instanceId, instances);
11429
+ return json(res, 201, { instanceId, instances: await describeInstances() });
11430
+ }
11431
+ finally {
11432
+ providerConfigBusy = false;
11433
+ }
11243
11434
  }
11244
11435
  const authStatus = /^\/api\/instances\/([\w.-]+)\/auth\/status$/.exec(path);
11245
11436
  if (method === "GET" && authStatus) {
@@ -11261,12 +11452,12 @@ const handleRequest = async (req, res) => {
11261
11452
  if (action === "refresh-models") {
11262
11453
  if (!(await registry.refreshModels(instanceId)))
11263
11454
  return json(res, 404, { error: "unknown instance" });
11264
- return json(res, 200, { instances: await registry.describe() });
11455
+ return json(res, 200, { instances: await describeInstances() });
11265
11456
  }
11266
11457
  if (action === "install") {
11267
11458
  if (!(await registry.installRuntime(instanceId)))
11268
11459
  return json(res, 404, { error: "managed installation is unavailable" });
11269
- return json(res, 200, { instances: await registry.describe() });
11460
+ return json(res, 200, { instances: await describeInstances() });
11270
11461
  }
11271
11462
  if (action === "auth/start") {
11272
11463
  const instance = registry.get(instanceId);
@@ -11372,38 +11563,80 @@ const handleRequest = async (req, res) => {
11372
11563
  }
11373
11564
  // ── per-instance CLI path override (custom builds / versioned bins) ──
11374
11565
  // PATCH /api/instances/:id {cli: "/path/to/cli" | ""} — "" reverts to the
11375
- // driver default. Kills in-flight turns like any provider reload.
11566
+ // driver default. Only this idle instance is replaced; siblings keep running.
11376
11567
  const instancePatch = /^\/api\/instances\/([\w.-]+)$/.exec(path);
11377
11568
  if (method === "PATCH" && instancePatch) {
11378
11569
  // same non-simple-request gate as the local-VM lifecycle routes
11379
11570
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
11380
11571
  return json(res, 415, { error: "content-type must be application/json" });
11381
11572
  }
11382
- const body = await readBody(req);
11383
- if (typeof body?.cli !== "string")
11384
- return json(res, 400, { error: "cli must be a string" });
11385
- if (/[\n\r]/.test(body.cli))
11386
- return json(res, 400, { error: "cli must not contain newlines" });
11573
+ const parsed = instanceSettingsSchema.safeParse(await readBody(req, 16384));
11574
+ if (!parsed.success)
11575
+ return json(res, 400, { error: "Supply a valid CLI path, account name or configuration directory." });
11576
+ const body = parsed.data;
11577
+ const instanceId = instancePatch[1];
11387
11578
  if (providerConfigBusy)
11388
11579
  return json(res, 409, { error: "provider settings are already being updated" });
11580
+ if (store.bots.some((bot) => bot.busy && bot.modelSelection.instanceId === instanceId)) {
11581
+ return json(res, 409, { error: "Wait for bots using this account to finish before changing its settings." });
11582
+ }
11389
11583
  providerConfigBusy = true;
11584
+ providerInstancesChanging.add(instanceId);
11390
11585
  try {
11391
- const result = withInstanceCli(cfg, instancePatch[1], body.cli);
11392
- if (!result.ok)
11393
- return json(res, 404, { error: `unknown instance "${instancePatch[1]}"` });
11394
- // persist the whole instances map this rebuild produced — a fresh
11395
- // saveConfig({instances}) merge would re-derive defaults identically,
11396
- // but writing the resolved map keeps disk and runtime in lockstep
11397
- saveConfig({ instances: result.config.instances });
11398
- Object.assign(cfg, loadConfig());
11399
- await reloadProviders();
11400
- // rescan BEFORE describe(): the response's cliCandidates are computed
11401
- // from the memoized PATH, so resetting after would answer this request
11402
- // with the pre-reset cache
11403
- resetPathCache();
11404
- return json(res, 200, { instances: await registry.describe() });
11586
+ const result = body.cli === undefined ? { ok: true, config: cfg } : withInstanceCli(cfg, instanceId, body.cli);
11587
+ const instances = persistableInstanceConfigs(result.config);
11588
+ if (!result.ok || !Object.hasOwn(instances, instanceId))
11589
+ return json(res, 404, { error: `unknown instance "${instanceId}"` });
11590
+ const entry = instances[instanceId];
11591
+ if ((body.displayName !== undefined || body.configDir !== undefined) && entry.driver !== "claudeAgent") {
11592
+ return json(res, 400, { error: "Account settings are currently available for Claude only." });
11593
+ }
11594
+ if (body.displayName !== undefined)
11595
+ entry.displayName = body.displayName;
11596
+ if (body.configDir !== undefined) {
11597
+ let previousDir;
11598
+ try {
11599
+ previousDir = configuredAccountDirectory(entry);
11600
+ }
11601
+ catch { /* Allow repairing an unused malformed account. */ }
11602
+ entry.config = { ...entry.config, configDir: body.configDir };
11603
+ if (previousDir !== configuredAccountDirectory(entry)) {
11604
+ const used = store.bots.some((bot) => bot.modelSelection.instanceId === instanceId || store.tasks(bot.id).some((task) => task.resumeCursors[instanceId] || task.lastInstanceId === instanceId));
11605
+ if (used)
11606
+ return json(res, 409, { error: "This account is used by bots or conversation history. Add another account and select it for the bot instead." });
11607
+ assertSeparateClaudeAccount(instances, instanceId, entry);
11608
+ }
11609
+ }
11610
+ await persistProviderInstance(instanceId, instances);
11611
+ return json(res, 200, { instances: await describeInstances() });
11612
+ }
11613
+ finally {
11614
+ providerInstancesChanging.delete(instanceId);
11615
+ providerConfigBusy = false;
11616
+ }
11617
+ }
11618
+ if (method === "DELETE" && instancePatch) {
11619
+ const instanceId = instancePatch[1];
11620
+ if (providerConfigBusy)
11621
+ return json(res, 409, { error: "provider settings are already being updated" });
11622
+ const instances = persistableInstanceConfigs(cfg);
11623
+ if (!Object.hasOwn(instances, instanceId))
11624
+ return json(res, 404, { error: "unknown instance" });
11625
+ if (instances[instanceId].driver !== "claudeAgent" || instanceId === "claude") {
11626
+ return json(res, 400, { error: "Only added Claude accounts can be removed here." });
11627
+ }
11628
+ if (cfg.defaultModelSelection?.instanceId === instanceId || store.bots.some((bot) => bot.modelSelection.instanceId === instanceId)) {
11629
+ return json(res, 409, { error: "Choose another account for the bots and default model using this account before removing it." });
11630
+ }
11631
+ providerConfigBusy = true;
11632
+ providerInstancesChanging.add(instanceId);
11633
+ try {
11634
+ delete instances[instanceId];
11635
+ await persistProviderInstance(instanceId, instances);
11636
+ return json(res, 200, { instances: await describeInstances() });
11405
11637
  }
11406
11638
  finally {
11639
+ providerInstancesChanging.delete(instanceId);
11407
11640
  providerConfigBusy = false;
11408
11641
  }
11409
11642
  }
@@ -11520,12 +11753,24 @@ const handleRequest = async (req, res) => {
11520
11753
  return json(res, 400, { error: "nothing to save" });
11521
11754
  if (providerConfigBusy)
11522
11755
  return json(res, 409, { error: "provider settings are already being updated" });
11756
+ if (patch.browserProfiles !== undefined && body.expectedBrowserProfiles !== undefined) {
11757
+ const current = (cfg.browserProfiles ?? []).map(({ id, name }) => ({ id, name }));
11758
+ if (JSON.stringify(body.expectedBrowserProfiles) !== JSON.stringify(current)) {
11759
+ return json(res, 409, { error: "Browser profiles changed in another window. Review the refreshed list and try again." });
11760
+ }
11761
+ }
11523
11762
  const disablingBuiltInBrowser = patch.features?.browser === false && builtInBrowserEnabled(cfg);
11524
11763
  const removedBrowserProfileIds = patch.browserProfiles === undefined
11525
11764
  ? []
11526
11765
  : (cfg.browserProfiles ?? [])
11527
11766
  .map((profile) => profile.id)
11528
11767
  .filter((id) => !patch.browserProfiles.some((profile) => profile.id === id));
11768
+ const profileControlConflict = () => removedBrowserProfileIds.some((id) => {
11769
+ const target = browserProfilePartitionTarget(cfg, id);
11770
+ return target && browserRuntime.heldBy(browserSessionId("", target.partitionId));
11771
+ });
11772
+ if (profileControlConflict())
11773
+ return json(res, 409, { error: "Release browser control before deleting its profile." });
11529
11774
  if (patch.browserProfiles !== undefined) {
11530
11775
  const currentProfiles = new Map((cfg.browserProfiles ?? []).map((profile) => [profile.id, profile]));
11531
11776
  const nextProfiles = patch.browserProfiles.map((profile) => {
@@ -11754,6 +11999,8 @@ const handleRequest = async (req, res) => {
11754
11999
  return json(res, 409, { error: conflict });
11755
12000
  }
11756
12001
  const browserCleanupRequests = [];
12002
+ if (profileControlConflict())
12003
+ return json(res, 409, { error: "Release browser control before deleting its profile." });
11757
12004
  try {
11758
12005
  for (const profileId of removedBrowserProfileIds) {
11759
12006
  const target = browserProfilePartitionTarget(cfg, profileId);
@@ -11818,6 +12065,12 @@ const handleRequest = async (req, res) => {
11818
12065
  throw error;
11819
12066
  }
11820
12067
  let browserReferenceCleanupError = null;
12068
+ if (disablingBuiltInBrowser)
12069
+ browserLive.closeAll();
12070
+ for (const request of browserCleanupRequests) {
12071
+ if (request.kind === "profile")
12072
+ browserLive.closeForSession(browserSessionId("", request.partitionId));
12073
+ }
11821
12074
  if (patch.browserProfiles !== undefined) {
11822
12075
  const retained = new Set(patch.browserProfiles.map((profile) => profile.id));
11823
12076
  try {
@@ -12310,6 +12563,21 @@ calendarCalls.start();
12310
12563
  // Resolve the edition before accepting requests so /api/edition is never a guess.
12311
12564
  console.log(describeEdition(await loadEnterpriseLayer()));
12312
12565
  console.log(describeBrand(loadBrand()));
12566
+ // Reclaim upload partials a previous run crashed out of, and warm the
12567
+ // attachment quota cache off the same scan. This used to happen implicitly on
12568
+ // every reservation, which is exactly what made uploads quadratic in
12569
+ // directory size; do the initial sweep before accepting requests.
12570
+ // ponytail: once per boot, not periodic. A partial orphaned while this
12571
+ // process is up survives until the next restart — add a timer only if that
12572
+ // shows up as real quota pressure.
12573
+ try {
12574
+ const reclaimedPartials = cleanupStaleAttachmentPartials();
12575
+ if (reclaimedPartials > 0)
12576
+ console.log(`reclaimed ${reclaimedPartials} abandoned upload partial(s)`);
12577
+ }
12578
+ catch (error) {
12579
+ console.warn(`attachments: startup partial cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
12580
+ }
12313
12581
  server.listen(PORT, "127.0.0.1", () => {
12314
12582
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
12315
12583
  });
@@ -12335,6 +12603,7 @@ const gracefulShutdown = createGracefulShutdown({
12335
12603
  // asynchronous shutdown jobs drain. Invalidate their turn bearers before
12336
12604
  // any cleanup function reaches an await.
12337
12605
  revokeAllInternalCapabilities();
12606
+ browserLive.closeAll();
12338
12607
  for (const idle of localVmIdles.values())
12339
12608
  idle.cancel();
12340
12609
  vps.closeAllVpsDesktopTunnels();
@@ -12345,6 +12614,10 @@ const gracefulShutdown = createGracefulShutdown({
12345
12614
  tunnelListener?.close();
12346
12615
  },
12347
12616
  () => registry.disposeAll(),
12617
+ async () => {
12618
+ await Promise.all([...temporaryBrowserSessions.keys()].map((botId) => forgetTemporaryBrowser(botId)));
12619
+ await browserRuntime.closeAll();
12620
+ },
12348
12621
  () => flushAllProfileHistory(),
12349
12622
  ],
12350
12623
  // Cleanup jobs run concurrently. Release only after they settle (or reach