openmausbot 0.1.63 → 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.
Files changed (34) hide show
  1. package/dist/assets/index-BftlaHAF.css +1 -0
  2. package/dist/assets/index-DuR3H-Hf.js +278 -0
  3. package/dist/assets/{index-C-SoIGdn.js → index-kW5L1QeO.js} +1 -1
  4. package/dist/index.html +2 -2
  5. package/dist-server/browser-proxy.js +121 -0
  6. package/dist-server/container-mcp.js +4 -1
  7. package/dist-server/index.js +17996 -15876
  8. package/dist-server/openmausbot.js +752 -245
  9. package/dist-server/pair-cli.js +752 -245
  10. package/dist-server/prepare-cloudflared.js +10 -2
  11. package/dist-server/proxy-paths.js +1 -0
  12. package/dist-server/server/attachments.js +140 -16
  13. package/dist-server/server/browser-engine-release.js +10 -8
  14. package/dist-server/server/browser-engine.js +226 -26
  15. package/dist-server/server/browser-live.js +567 -0
  16. package/dist-server/server/browser-proxy.js +129 -0
  17. package/dist-server/server/browser-runtime.js +370 -0
  18. package/dist-server/server/claude-accounts.js +103 -0
  19. package/dist-server/server/config.js +31 -20
  20. package/dist-server/server/custom-domain.js +196 -0
  21. package/dist-server/server/drivers/claude.js +77 -19
  22. package/dist-server/server/drivers/codex-device-auth.js +234 -0
  23. package/dist-server/server/drivers/codex.js +10 -0
  24. package/dist-server/server/harness/bus.js +9 -4
  25. package/dist-server/server/harness/registry.js +15 -0
  26. package/dist-server/server/index.js +446 -91
  27. package/dist-server/server/provider-auth-sessions.js +118 -0
  28. package/dist-server/server/proxy-paths.js +1 -0
  29. package/dist-server/server/sse-fanout.js +53 -0
  30. package/dist-server/src/lib/composer-attachments.js +23 -6
  31. package/dist-server/vps-container-mcp.js +4 -1
  32. package/package.json +1 -1
  33. package/dist/assets/index-BuntoOSX.js +0 -264
  34. package/dist/assets/index-oPMv6a_k.css +0 -1
@@ -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,9 +120,12 @@ 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";
123
+ import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
124
+ import { ProviderAuthSessions } from "./provider-auth-sessions.js";
120
125
  import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
121
126
  import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
122
127
  import { describeBrand, loadBrand } from "./brand.js";
128
+ import { deliverSseFrame } from "./sse-fanout.js";
123
129
  import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, PhoneSecretSubmissionRegistry, assertPhoneSecretRequestMatches, phoneSecretOperationId, } from "./phone-secret.js";
124
130
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
125
131
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
@@ -169,9 +175,30 @@ const DESKTOP_MANAGED = process.env.OMB_DESKTOP_PARENT === "1";
169
175
  let desktopMutationToken = DESKTOP_MANAGED ? "" : undefined;
170
176
  let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
171
177
  // Where remote clients reach this server (a proxy's public address); pairing URLs use it.
172
- const PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
178
+ const FALLBACK_PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
173
179
  const cfg = loadConfig();
180
+ const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
181
+ let customDomainRevision = 0;
182
+ function savedCustomDomain() {
183
+ if (DESKTOP_MANAGED || !cfg.customDomain)
184
+ return null;
185
+ try {
186
+ return normalizeCustomDomain(cfg.customDomain);
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ function publicUrl() { return savedCustomDomain() ?? FALLBACK_PUBLIC_URL; }
193
+ function customDomainStatus() {
194
+ return {
195
+ customDomain: savedCustomDomain(), publicUrl: publicUrl(), fallbackUrl: FALLBACK_PUBLIC_URL,
196
+ supported: !DESKTOP_MANAGED, appPort: PORT, webhookPort: WEBHOOK_PORT,
197
+ serverIpv4: DESKTOP_MANAGED ? null : customDomainIpv4(),
198
+ };
199
+ }
174
200
  const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
201
+ const providerAuthSessions = new ProviderAuthSessions();
175
202
  await registry.load(instanceConfigs(cfg));
176
203
  const bundledSkills = loadBundledSkills();
177
204
  const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
@@ -225,7 +252,7 @@ const browserCleanup = new BrowserCleanupCoordinator({
225
252
  ? Promise.all(sessions.map(async (session) => {
226
253
  const ok = await clearBrowserSessionState(status.binaryPath, session, { encryptionKey: browserEngineEncryptionKey() });
227
254
  if (!ok)
228
- 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.`);
229
256
  return ok;
230
257
  }))
231
258
  : Promise.resolve([true]);
@@ -420,10 +447,7 @@ function purgeGeneratedImagesForThread(threadId) {
420
447
  continue;
421
448
  generatedImagesByTurn.delete(key);
422
449
  for (const attachment of attachments) {
423
- try {
424
- unlinkSync(attachment.path);
425
- }
426
- catch { }
450
+ deleteAttachment(attachment.path);
427
451
  }
428
452
  }
429
453
  }
@@ -443,10 +467,7 @@ function retireProviderTurn(turnId) {
443
467
  continue;
444
468
  generatedImagesByTurn.delete(key);
445
469
  for (const attachment of attachments) {
446
- try {
447
- unlinkSync(attachment.path);
448
- }
449
- catch { }
470
+ deleteAttachment(attachment.path);
450
471
  }
451
472
  }
452
473
  }
@@ -501,7 +522,40 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
501
522
  /** The bot's browser for this turn: agent-browser, one isolated session per
502
523
  * browser profile or per bot (docs/plans/browser-engine.md). Null, with the
503
524
  * reason logged once, when the engine is not on this machine. */
504
- 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) {
505
559
  const status = browserEngineStatus();
506
560
  if (status.kind !== "ready") {
507
561
  if (!engineUnavailableLogged) {
@@ -513,16 +567,29 @@ function browserIntegration(botId, profile) {
513
567
  // A profile that no longer exists falls back to the bot's own session.
514
568
  const profileTarget = profile && profile !== "guest" ? browserProfilePartitionTarget(cfg, profile) : null;
515
569
  const partitionId = profile === "guest" ? "guest" : (profileTarget?.partitionId ?? "");
516
- const session = browserSessionId(botId, partitionId);
517
- return {
518
- profile: partitionId,
519
- integration: agentBrowserIntegration({
520
- binaryPath: status.binaryPath,
521
- session,
522
- encryptionKey: browserEngineEncryptionKey(),
523
- persistent: profile !== "guest",
524
- }),
525
- };
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
+ } };
526
593
  }
527
594
  let engineUnavailableLogged = false;
528
595
  let browserEngineInstall = null;
@@ -694,6 +761,9 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
694
761
  return { ok: false, status: 409, error: "the bot is working — stop it before changing models" };
695
762
  }
696
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
+ }
697
767
  // Model IDs remain free-form at the app's general API boundary. Custom
698
768
  // engines can accept IDs that are not in their discovery catalog, and
699
769
  // several drivers only learn the final catalog when a turn starts. The
@@ -1658,6 +1728,7 @@ function messageWindow(threadId, messageId, limit) {
1658
1728
  }
1659
1729
  const sseClients = new Set();
1660
1730
  sessions.onSessionRevoked((sessionId) => {
1731
+ providerAuthSessions.revokeOwner(sessionId);
1661
1732
  for (const client of sseClients) {
1662
1733
  if (client.sessionId !== sessionId)
1663
1734
  continue;
@@ -1720,10 +1791,9 @@ function broadcast(payload) {
1720
1791
  for (const client of [...sseClients]) {
1721
1792
  if (!wants(client, kind))
1722
1793
  continue;
1723
- try {
1724
- client.res.write(client.admin ? frame : clientFrame);
1725
- }
1726
- catch {
1794
+ // Screen frames are replaceable and durable events are not: see
1795
+ // ./sse-fanout.ts for the backpressure/bound decision this makes.
1796
+ if (deliverSseFrame(client, kind, client.admin ? frame : clientFrame) === "disconnected") {
1727
1797
  sseClients.delete(client);
1728
1798
  }
1729
1799
  }
@@ -3410,6 +3480,9 @@ async function startTurn(botId, text, opts) {
3410
3480
  }
3411
3481
  console.error(`[omb-turn] bot=${botId} text=${JSON.stringify(resolvedImages.text.slice(0, 70))} images=${turnImages.length} depth=${commsDepth} card=${Boolean(opts?.cardContinuation)}`);
3412
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
+ }
3413
3486
  const model = opts?.runOn === "cloud" ? instance.models.default : bot.modelSelection.model;
3414
3487
  // a cloud routine borrows the instance default model, so it borrows no
3415
3488
  // per-bot effort either
@@ -3853,7 +3926,7 @@ async function startTurn(botId, text, opts) {
3853
3926
  liveBot.browser !== false &&
3854
3927
  instance.adapter.capabilities.browserMcp === true) {
3855
3928
  const selectedProfile = liveBot.browserProfile;
3856
- browser = browserIntegration(bot.id, selectedProfile);
3929
+ browser = await browserIntegration(bot.id, selectedProfile, { threadId, generation: dispatchClaimId });
3857
3930
  if (browser)
3858
3931
  integrations.browser = browser.integration;
3859
3932
  // The browser lost its frame source when the Electron surface was
@@ -3863,8 +3936,9 @@ async function startTurn(botId, text, opts) {
3863
3936
  // the work was a web page, because agent-browser runs its own headless
3864
3937
  // Chrome on the host rather than inside that desktop.
3865
3938
  if (browser) {
3866
- const frame = { binaryPath: browser.integration.command, env: browser.integration.env };
3867
- 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));
3868
3942
  }
3869
3943
  }
3870
3944
  // A cancelled adapter can be between accepting sendTurn and revealing
@@ -4595,6 +4669,10 @@ setupRetry = 0) {
4595
4669
  const preparedComposio = bot.composio;
4596
4670
  const instance = registry.get(bot.modelSelection.instanceId);
4597
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
+ }
4598
4676
  if (!instance) {
4599
4677
  const message = `${bot.name}'s model is unavailable`;
4600
4678
  store.appendMessage(threadId, {
@@ -4777,6 +4855,11 @@ setupRetry = 0) {
4777
4855
  return true;
4778
4856
  }
4779
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 });
4780
4863
  orchestration?.onClaimed?.();
4781
4864
  // Connected-app discovery above can yield for a network round trip. A
4782
4865
  // profile may be removed, or the browser feature switched off, during that
@@ -4787,24 +4870,19 @@ setupRetry = 0) {
4787
4870
  readyBot.browser !== false &&
4788
4871
  instance.adapter.capabilities.browserMcp === true) {
4789
4872
  const selectedProfile = readyBot.browserProfile;
4790
- const browser = browserIntegration(readyBot.id, selectedProfile);
4873
+ const browser = await browserIntegration(readyBot.id, selectedProfile, { threadId, generation: internalGeneration });
4791
4874
  if (browser)
4792
4875
  integrations.browser = browser.integration;
4793
4876
  }
4794
- // Stop/delete may land while Electron is registering the capability. The
4795
- // callback above prevents publication; this second check also unwinds the
4796
- // 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.
4797
4880
  const browserReadyBot = store.bot(readyBot.id);
4798
- if (isCancelled?.() || !browserReadyBot || !browserReadyBot.busy) {
4799
- if (browserReadyBot?.busy) {
4800
- store.setActivity(browserReadyBot.id, "idle");
4801
- retryDelegationsWaitingOn(browserReadyBot.id);
4802
- }
4881
+ if (isCancelled?.() || !browserReadyBot?.busy ||
4882
+ groupSpeakers.get(threadId) !== roomSpeaker ||
4883
+ activeInternalGenerationByThread.get(threadId) !== internalGeneration) {
4803
4884
  return false;
4804
4885
  }
4805
- store.patchGroup(readyGroup.id, { busyBotId: bot.id }); // the store's change stream carries the frame
4806
- roomSpeaker = { botId: bot.id, name: bot.name, color: bot.color };
4807
- groupSpeakers.set(threadId, roomSpeaker);
4808
4886
  // Room and Goal turns use the speaker's desktop, never the coordinator's.
4809
4887
  // Claim the same lease as direct turns before asynchronous VM setup.
4810
4888
  if (readyBot.computer === "vm") {
@@ -6570,9 +6648,45 @@ function persistMcpServers(next) {
6570
6648
  // when an empty section is omitted by an older config file.
6571
6649
  cfg.mcpServers = next;
6572
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
+ }
6573
6686
  /** Rebuild the provider fleet after a config change so new keys take
6574
6687
  * effect without a server restart (kills any in-flight turns). */
6575
6688
  async function reloadProviders() {
6689
+ providerAuthSessions.clear();
6576
6690
  // Every provider process is about to die. Revoke all turn capabilities in
6577
6691
  // one synchronous step before the first teardown await, including room/task
6578
6692
  // threads that are not a bot's default DM.
@@ -6609,6 +6723,7 @@ async function reloadProviders() {
6609
6723
  // and reload sequence single-flight so two settings requests cannot drop one
6610
6724
  // another's changes or dispose a fleet while another reload is creating it.
6611
6725
  let providerConfigBusy = false;
6726
+ const providerInstancesChanging = new Set();
6612
6727
  let mcpConfigBusy = false;
6613
6728
  const MAX_CONCURRENT_MCP_PROBES = 2;
6614
6729
  let mcpProbesInFlight = 0;
@@ -6716,6 +6831,12 @@ const handleRequest = async (req, res) => {
6716
6831
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
6717
6832
  return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }));
6718
6833
  }
6834
+ const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
6835
+ if (method === "GET" && domainCheck) {
6836
+ res.setHeader("cache-control", "no-store");
6837
+ const challenge = customDomainVerifier.challenge(domainCheck[1]);
6838
+ return json(res, challenge ? 200 : 404, challenge ?? { error: "No active domain check." });
6839
+ }
6719
6840
  if (method === "POST" && path === "/api/auth/pair") {
6720
6841
  // JSON only: a cross-site HTML form cannot send this content type
6721
6842
  // without a preflight, so a stray unused code cannot be planted as a
@@ -6803,7 +6924,7 @@ const handleRequest = async (req, res) => {
6803
6924
  const scopes = Array.isArray(requested) ? requested.filter((v) => v === "admin" || v === "client") : undefined;
6804
6925
  const opened = sessions.openPairing({ label: typeof body?.label === "string" ? body.label : undefined, scopes });
6805
6926
  const origin = requestOrigin(req);
6806
- const base = PUBLIC_URL ?? (auth.kind === "session" && origin ? origin : null);
6927
+ const base = publicUrl() ?? (auth.kind === "session" && origin ? origin : null);
6807
6928
  const code = formatPairingCode(opened.code);
6808
6929
  return json(res, 200, {
6809
6930
  id: opened.id,
@@ -6816,7 +6937,42 @@ const handleRequest = async (req, res) => {
6816
6937
  });
6817
6938
  }
6818
6939
  if (method === "GET" && path === "/api/auth/pairing")
6819
- return json(res, 200, { pairings: sessions.openPairings(), publicUrl: PUBLIC_URL });
6940
+ return json(res, 200, { pairings: sessions.openPairings(), publicUrl: publicUrl() });
6941
+ // Admin-only via request-auth's default deny. Connecting a domain only
6942
+ // changes future pairing links; DNS, proxy setup, webhooks and all existing
6943
+ // sessions remain untouched. The tunnel/deployment URL is kept as fallback.
6944
+ if (path === "/api/settings/custom-domain") {
6945
+ res.setHeader("cache-control", "no-store");
6946
+ if (method === "GET")
6947
+ return json(res, 200, customDomainStatus());
6948
+ if (method === "POST" || method === "DELETE") {
6949
+ if (DESKTOP_MANAGED)
6950
+ return json(res, 409, { error: "Custom domains are configured on a self-hosted OpenMausBot server, not the desktop companion." });
6951
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
6952
+ return json(res, 415, { error: "content-type must be application/json" });
6953
+ }
6954
+ if (method === "DELETE") {
6955
+ saveConfig({ customDomain: "" });
6956
+ cfg.customDomain = "";
6957
+ customDomainRevision++;
6958
+ return json(res, 200, customDomainStatus());
6959
+ }
6960
+ const body = await readBody(req, 4096);
6961
+ if (typeof body?.domain !== "string")
6962
+ return json(res, 400, { error: "Enter your domain name." });
6963
+ const revision = customDomainRevision;
6964
+ const verified = await customDomainVerifier.verify(body.domain);
6965
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
6966
+ return json(res, 401, { error: "Your session ended. Sign in again before connecting a domain." });
6967
+ }
6968
+ if (revision !== customDomainRevision)
6969
+ return json(res, 409, { error: "Domain settings changed during verification. Try again." });
6970
+ saveConfig({ customDomain: verified.origin });
6971
+ cfg.customDomain = verified.origin;
6972
+ customDomainRevision++;
6973
+ return json(res, 200, customDomainStatus());
6974
+ }
6975
+ }
6820
6976
  m = path.match(/^\/api\/auth\/pairing\/([\w-]+)$/);
6821
6977
  if (m && method === "DELETE") {
6822
6978
  const cancelled = sessions.cancelPairing(m[1]);
@@ -6878,11 +7034,13 @@ const handleRequest = async (req, res) => {
6878
7034
  if (!internalSender) {
6879
7035
  return json(res, 401, { error: "unauthorized" });
6880
7036
  }
6881
- const requiredCapabilityKind = path.startsWith("/api/internal/connectors/")
6882
- ? "connectors"
6883
- : path === "/api/internal/computer-control"
6884
- ? "computer"
6885
- : "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";
6886
7044
  if (internalCapability.kind !== requiredCapabilityKind) {
6887
7045
  return json(res, 403, { error: "this internal capability cannot access that service" });
6888
7046
  }
@@ -6926,6 +7084,30 @@ const handleRequest = async (req, res) => {
6926
7084
  throw Object.assign(new Error("the internal turn capability has expired"), { status: 401 });
6927
7085
  }
6928
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
+ }
6929
7111
  if (method === "GET" && path === "/api/internal/agents") {
6930
7112
  const sender = internalSender;
6931
7113
  // title/description included so the caller can judge the team (who
@@ -8018,8 +8200,52 @@ const handleRequest = async (req, res) => {
8018
8200
  : json(res, 404, { error: "no such webhook" });
8019
8201
  }
8020
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
+ }
8021
8242
  if (method === "GET" && path === "/api/events") {
8022
- const client = { res, admin: auth.scopes.includes("admin"), screens: url.searchParams.get("screens") !== "off" };
8243
+ const client = {
8244
+ res,
8245
+ admin: auth.scopes.includes("admin"),
8246
+ screens: url.searchParams.get("screens") !== "off",
8247
+ backpressured: false,
8248
+ };
8023
8249
  if (auth.kind === "session")
8024
8250
  client.sessionId = auth.session.id;
8025
8251
  res.writeHead(200, {
@@ -9401,10 +9627,7 @@ const handleRequest = async (req, res) => {
9401
9627
  if (!bot) {
9402
9628
  // There are no awaits between the refreshed lookup and this patch, but
9403
9629
  // keep the attachment invariant explicit if the store ever changes.
9404
- try {
9405
- unlinkSync(saved.path);
9406
- }
9407
- catch { }
9630
+ deleteAttachment(saved.path);
9408
9631
  return json(res, 404, { error: "no such bot" });
9409
9632
  }
9410
9633
  const visible = wireBot(bot);
@@ -9510,6 +9733,8 @@ const handleRequest = async (req, res) => {
9510
9733
  if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
9511
9734
  return json(res, 400, { error: "requireAvailableModel must be true or false" });
9512
9735
  }
9736
+ const beforeBrowserProfile = existingBot?.browserProfile;
9737
+ const beforeBrowserEnabled = existingBot?.browser;
9513
9738
  // Neither Codex (free-form string field) nor Grok (lazy, logs-only)
9514
9739
  // rejects an unknown effort level at their own boundary — this is the
9515
9740
  // only real gate, so it stays. But it fires only when the target
@@ -9626,6 +9851,10 @@ const handleRequest = async (req, res) => {
9626
9851
  if (existingBot?.busy && requestedProfile !== existingBot.browserProfile) {
9627
9852
  return json(res, 409, { error: "stop this bot's turn before changing its browser profile" });
9628
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
+ }
9629
9858
  if (requestedProfile === undefined)
9630
9859
  patch.browserProfile = undefined;
9631
9860
  else if (typeof requestedProfile === "string" &&
@@ -9838,6 +10067,12 @@ const handleRequest = async (req, res) => {
9838
10067
  section !== undefined &&
9839
10068
  sectionKey(existingBot?.section) !== sectionKey(section);
9840
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
+ }
9841
10076
  if (profile.patch.soul !== undefined) {
9842
10077
  // A mixed settings request must not turn a runtime revocation into
9843
10078
  // a persist-first edit. Apply runtime fields with their existing
@@ -9854,6 +10089,12 @@ const handleRequest = async (req, res) => {
9854
10089
  }
9855
10090
  if (!bot)
9856
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
+ }
9857
10098
  const chiefChanges = body.chiefOfStaff === true || chiefMovedSections
9858
10099
  ? store.setChiefOfStaff(bot.id)
9859
10100
  : [];
@@ -10050,6 +10291,8 @@ const handleRequest = async (req, res) => {
10050
10291
  localVmIdles.get(target.key)?.cancel();
10051
10292
  localVmIdles.delete(target.key);
10052
10293
  store.deleteBot(bot.id);
10294
+ browserLive.closeForBot(bot.id);
10295
+ await forgetTemporaryBrowser(bot.id);
10053
10296
  }
10054
10297
  catch (error) {
10055
10298
  if (browserCleanupRequest)
@@ -11168,7 +11411,32 @@ const handleRequest = async (req, res) => {
11168
11411
  // Windows never pushes PATH changes into a live process, so without
11169
11412
  // this the answer is frozen at boot and "check again" is a no-op.
11170
11413
  resetPathCache();
11171
- 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
+ }
11434
+ }
11435
+ const authStatus = /^\/api\/instances\/([\w.-]+)\/auth\/status$/.exec(path);
11436
+ if (method === "GET" && authStatus) {
11437
+ res.setHeader("cache-control", "no-store");
11438
+ const state = await providerAuthSessions.status(authStatus[1], auth.kind === "session" ? auth.session.id : "loopback", url.searchParams.get("flowId") ?? "");
11439
+ return json(res, 200, { auth: state });
11172
11440
  }
11173
11441
  const instanceAction = /^\/api\/instances\/([\w.-]+)\/(refresh-models|install|auth\/start|auth\/complete|auth\/cancel)$/.exec(path);
11174
11442
  if (method === "POST" && instanceAction) {
@@ -11177,22 +11445,31 @@ const handleRequest = async (req, res) => {
11177
11445
  }
11178
11446
  const instanceId = instanceAction[1];
11179
11447
  const action = instanceAction[2];
11448
+ const owner = auth.kind === "session" ? auth.session.id : "loopback";
11449
+ if (action.startsWith("auth/"))
11450
+ res.setHeader("cache-control", "no-store");
11180
11451
  try {
11181
11452
  if (action === "refresh-models") {
11182
11453
  if (!(await registry.refreshModels(instanceId)))
11183
11454
  return json(res, 404, { error: "unknown instance" });
11184
- return json(res, 200, { instances: await registry.describe() });
11455
+ return json(res, 200, { instances: await describeInstances() });
11185
11456
  }
11186
11457
  if (action === "install") {
11187
11458
  if (!(await registry.installRuntime(instanceId)))
11188
11459
  return json(res, 404, { error: "managed installation is unavailable" });
11189
- return json(res, 200, { instances: await registry.describe() });
11460
+ return json(res, 200, { instances: await describeInstances() });
11190
11461
  }
11191
11462
  if (action === "auth/start") {
11192
- const auth = await registry.startAuthentication(instanceId);
11193
- if (!auth)
11194
- return json(res, 404, { error: "account setup is unavailable" });
11195
- return json(res, 200, { auth });
11463
+ const instance = registry.get(instanceId);
11464
+ if (!instance)
11465
+ return json(res, 404, { error: "unknown instance" });
11466
+ const started = await providerAuthSessions.start(instance, owner);
11467
+ // Revocation can arrive while the CLI is obtaining a device code.
11468
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
11469
+ providerAuthSessions.revokeOwner(owner);
11470
+ return json(res, 401, { error: "Your session ended. Start a new sign-in." });
11471
+ }
11472
+ return json(res, 200, { auth: started });
11196
11473
  }
11197
11474
  if (action === "auth/complete") {
11198
11475
  const body = await readBody(req);
@@ -11200,20 +11477,16 @@ const handleRequest = async (req, res) => {
11200
11477
  const callbackUrl = typeof body?.callbackUrl === "string" ? body.callbackUrl : "";
11201
11478
  if (!flowId || !callbackUrl)
11202
11479
  return json(res, 400, { error: "flowId and callbackUrl are required" });
11203
- if (!(await registry.completeAuthentication(instanceId, flowId, callbackUrl))) {
11204
- return json(res, 404, { error: "account setup is unavailable" });
11205
- }
11480
+ await providerAuthSessions.complete(instanceId, owner, flowId, callbackUrl);
11206
11481
  return json(res, 200, { ok: true });
11207
11482
  }
11208
- if (!(await registry.cancelAuthentication(instanceId))) {
11209
- return json(res, 404, { error: "account setup is unavailable" });
11210
- }
11483
+ const body = await readBody(req, 4096);
11484
+ await providerAuthSessions.cancel(instanceId, owner, typeof body?.flowId === "string" ? body.flowId : "");
11211
11485
  return json(res, 200, { ok: true });
11212
11486
  }
11213
11487
  catch (error) {
11214
- const status = error && typeof error === "object" && error.status === 409
11215
- ? 409
11216
- : 500;
11488
+ const requestedStatus = error && typeof error === "object" ? error.status : undefined;
11489
+ const status = typeof requestedStatus === "number" && [400, 401, 404, 409, 413, 415].includes(requestedStatus) ? requestedStatus : 500;
11217
11490
  return json(res, status, { error: error instanceof Error ? error.message : String(error) });
11218
11491
  }
11219
11492
  }
@@ -11290,38 +11563,80 @@ const handleRequest = async (req, res) => {
11290
11563
  }
11291
11564
  // ── per-instance CLI path override (custom builds / versioned bins) ──
11292
11565
  // PATCH /api/instances/:id {cli: "/path/to/cli" | ""} — "" reverts to the
11293
- // driver default. Kills in-flight turns like any provider reload.
11566
+ // driver default. Only this idle instance is replaced; siblings keep running.
11294
11567
  const instancePatch = /^\/api\/instances\/([\w.-]+)$/.exec(path);
11295
11568
  if (method === "PATCH" && instancePatch) {
11296
11569
  // same non-simple-request gate as the local-VM lifecycle routes
11297
11570
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
11298
11571
  return json(res, 415, { error: "content-type must be application/json" });
11299
11572
  }
11300
- const body = await readBody(req);
11301
- if (typeof body?.cli !== "string")
11302
- return json(res, 400, { error: "cli must be a string" });
11303
- if (/[\n\r]/.test(body.cli))
11304
- 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];
11305
11578
  if (providerConfigBusy)
11306
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
+ }
11307
11583
  providerConfigBusy = true;
11584
+ providerInstancesChanging.add(instanceId);
11308
11585
  try {
11309
- const result = withInstanceCli(cfg, instancePatch[1], body.cli);
11310
- if (!result.ok)
11311
- return json(res, 404, { error: `unknown instance "${instancePatch[1]}"` });
11312
- // persist the whole instances map this rebuild produced — a fresh
11313
- // saveConfig({instances}) merge would re-derive defaults identically,
11314
- // but writing the resolved map keeps disk and runtime in lockstep
11315
- saveConfig({ instances: result.config.instances });
11316
- Object.assign(cfg, loadConfig());
11317
- await reloadProviders();
11318
- // rescan BEFORE describe(): the response's cliCandidates are computed
11319
- // from the memoized PATH, so resetting after would answer this request
11320
- // with the pre-reset cache
11321
- resetPathCache();
11322
- 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() });
11323
11637
  }
11324
11638
  finally {
11639
+ providerInstancesChanging.delete(instanceId);
11325
11640
  providerConfigBusy = false;
11326
11641
  }
11327
11642
  }
@@ -11438,12 +11753,24 @@ const handleRequest = async (req, res) => {
11438
11753
  return json(res, 400, { error: "nothing to save" });
11439
11754
  if (providerConfigBusy)
11440
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
+ }
11441
11762
  const disablingBuiltInBrowser = patch.features?.browser === false && builtInBrowserEnabled(cfg);
11442
11763
  const removedBrowserProfileIds = patch.browserProfiles === undefined
11443
11764
  ? []
11444
11765
  : (cfg.browserProfiles ?? [])
11445
11766
  .map((profile) => profile.id)
11446
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." });
11447
11774
  if (patch.browserProfiles !== undefined) {
11448
11775
  const currentProfiles = new Map((cfg.browserProfiles ?? []).map((profile) => [profile.id, profile]));
11449
11776
  const nextProfiles = patch.browserProfiles.map((profile) => {
@@ -11672,6 +11999,8 @@ const handleRequest = async (req, res) => {
11672
11999
  return json(res, 409, { error: conflict });
11673
12000
  }
11674
12001
  const browserCleanupRequests = [];
12002
+ if (profileControlConflict())
12003
+ return json(res, 409, { error: "Release browser control before deleting its profile." });
11675
12004
  try {
11676
12005
  for (const profileId of removedBrowserProfileIds) {
11677
12006
  const target = browserProfilePartitionTarget(cfg, profileId);
@@ -11736,6 +12065,12 @@ const handleRequest = async (req, res) => {
11736
12065
  throw error;
11737
12066
  }
11738
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
+ }
11739
12074
  if (patch.browserProfiles !== undefined) {
11740
12075
  const retained = new Set(patch.browserProfiles.map((profile) => profile.id));
11741
12076
  try {
@@ -12228,6 +12563,21 @@ calendarCalls.start();
12228
12563
  // Resolve the edition before accepting requests so /api/edition is never a guess.
12229
12564
  console.log(describeEdition(await loadEnterpriseLayer()));
12230
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
+ }
12231
12581
  server.listen(PORT, "127.0.0.1", () => {
12232
12582
  console.log(`openmausbot server on http://127.0.0.1:${PORT}`);
12233
12583
  });
@@ -12253,6 +12603,7 @@ const gracefulShutdown = createGracefulShutdown({
12253
12603
  // asynchronous shutdown jobs drain. Invalidate their turn bearers before
12254
12604
  // any cleanup function reaches an await.
12255
12605
  revokeAllInternalCapabilities();
12606
+ browserLive.closeAll();
12256
12607
  for (const idle of localVmIdles.values())
12257
12608
  idle.cancel();
12258
12609
  vps.closeAllVpsDesktopTunnels();
@@ -12263,6 +12614,10 @@ const gracefulShutdown = createGracefulShutdown({
12263
12614
  tunnelListener?.close();
12264
12615
  },
12265
12616
  () => registry.disposeAll(),
12617
+ async () => {
12618
+ await Promise.all([...temporaryBrowserSessions.keys()].map((botId) => forgetTemporaryBrowser(botId)));
12619
+ await browserRuntime.closeAll();
12620
+ },
12266
12621
  () => flushAllProfileHistory(),
12267
12622
  ],
12268
12623
  // Cleanup jobs run concurrently. Release only after they settle (or reach