openmausbot 0.1.71 → 0.1.72

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 (44) hide show
  1. package/dist/assets/index-BFPYw6P-.js +296 -0
  2. package/dist/assets/{index-CJtyBonr.js → index-Bpai86ua.js} +1 -1
  3. package/dist/assets/index-D1odUdTi.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/listener.js +12 -2
  6. package/dist-server/container-mcp.js +26 -2
  7. package/dist-server/index.js +7477 -2481
  8. package/dist-server/local-computer.js +4 -0
  9. package/dist-server/openmausbot.js +1589 -565
  10. package/dist-server/pair-cli.js +1589 -565
  11. package/dist-server/server/bot-overview.js +28 -0
  12. package/dist-server/server/browser-engine.js +2 -1
  13. package/dist-server/server/calendar-calls.js +1 -0
  14. package/dist-server/server/cli.js +102 -1
  15. package/dist-server/server/config.js +57 -5
  16. package/dist-server/server/drivers/claude.js +14 -7
  17. package/dist-server/server/fleet-agent.js +161 -0
  18. package/dist-server/server/fleet-cli.js +225 -0
  19. package/dist-server/server/fleet-client.js +45 -0
  20. package/dist-server/server/fleet.js +410 -0
  21. package/dist-server/server/index.js +316 -8
  22. package/dist-server/server/local-computer.js +4 -0
  23. package/dist-server/server/mcp-registry.js +71 -3
  24. package/dist-server/server/prices.js +16 -0
  25. package/dist-server/server/provider-auth-sessions.js +3 -0
  26. package/dist-server/server/provider-key-check.js +66 -0
  27. package/dist-server/server/routines.js +1 -0
  28. package/dist-server/server/skill-learn.js +15 -1
  29. package/dist-server/server/spend.js +61 -0
  30. package/dist-server/server/system-prompt.js +15 -5
  31. package/dist-server/server/tailscale.js +2 -2
  32. package/dist-server/server/usage-ledger.js +239 -0
  33. package/dist-server/server/webhook-ingress.js +7 -2
  34. package/dist-server/server/workspace-backup-http.js +264 -0
  35. package/dist-server/server/workspace-backup-maintenance.js +40 -0
  36. package/dist-server/server/workspace-backup-policy.js +28 -0
  37. package/dist-server/server/workspace-backup.js +1022 -0
  38. package/dist-server/shared/learn-request.js +6 -0
  39. package/dist-server/shared/workspace-backup-client.js +15 -0
  40. package/dist-server/shared/workspace-backup.js +1 -0
  41. package/dist-server/vps-container-mcp.js +26 -2
  42. package/package.json +1 -1
  43. package/dist/assets/index-CYLdYtYp.css +0 -1
  44. package/dist/assets/index-Di4ovwu5.js +0 -289
@@ -17,7 +17,7 @@ import { updateClaudeCli } from "./claude-update.js";
17
17
  import { configuredAccountDirectory, assertSeparateClaudeAccount, claudeAccountInfo, createClaudeAccountSchema, instanceSettingsSchema, newClaudeAccount } from "./claude-accounts.js";
18
18
  import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrowserCleanupAcknowledged, } from "./browser-lifecycle-cleanup.js";
19
19
  import * as checkpoints from "./checkpoints.js";
20
- import { appendDecision, readDecisions } from "./decision-log.js";
20
+ import { appendDecision, readDecisions, flushDecisionLog } from "./decision-log.js";
21
21
  import { validateBotCwd } from "./bot-cwd.js";
22
22
  import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
23
23
  import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
@@ -38,17 +38,22 @@ import { ComputerControl } from "./computer-control.js";
38
38
  import { MAX_REMOTE_COMMAND_LENGTH } from "./remote-computer.js";
39
39
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
40
40
  import { registerEnginesBinDir } from "./engine-install.js";
41
+ import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
42
+ import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
43
+ import { assertWithinBudget, noteSpend, spendState } from "./spend.js";
44
+ import { fleetAvailable, fleetRequest, fleetSocketPath } from "./fleet-client.js";
45
+ import { entitled } from "./enterprise.js";
41
46
  import { describeSpawnFailure, execCli } from "./procs.js";
42
47
  import { blockedTarget, buildNotification } from "./notify.js";
43
48
  import { isEffortLevel, newId, } from "./contracts.js";
44
49
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
45
50
  import { decodeGeneratedImage } from "./generated-image.js";
46
- import { MAX_MCP_SERVERS, listMcpServers, parseMcpServerMutation, parseStoredMcpServer, } from "./mcp-registry.js";
51
+ import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
47
52
  import { probeMcpServer } from "./mcp-probe.js";
48
53
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
49
54
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
50
55
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
51
- import { readMessageText, recallMessages, searchMessages } from "./message-db.js";
56
+ import { readMessageText, recallMessages, searchMessages, closeMessageDb } from "./message-db.js";
52
57
  import { claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
53
58
  /** A session_read answer competes with the transcript for the context
54
59
  * window; a computer-use turn's output can run to hundreds of KB. */
@@ -81,7 +86,7 @@ import { fetchSkillFromSource } from "./skill-fetch.js";
81
86
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
82
87
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
83
88
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
84
- import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, CREDENTIAL_PROMPT, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
89
+ import { buildSystemPrompt, computerPrompt, mentionPrompt, COMPOSIO_PROMPT, customMcpPrompt, CREDENTIAL_PROMPT, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, } from "./system-prompt.js";
85
90
  import { readCuaConnection, gatedLocalComputer } from "./local-computer.js";
86
91
  import { discoverExistingPerBotLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
87
92
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -123,7 +128,10 @@ import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRe
123
128
  import { createGracefulShutdown } from "./graceful-shutdown.js";
124
129
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
125
130
  import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
126
- import { environmentDescriptor, loadEnvironmentId } from "./environment.js";
131
+ import { environmentDescriptor, loadEnvironmentId, serverVersion } from "./environment.js";
132
+ import { WorkspaceBackupMaintenance } from "./workspace-backup-maintenance.js";
133
+ import { createWorkspaceBackupRoutes, isWorkspaceBackupSessionControl } from "./workspace-backup-http.js";
134
+ import { applyPendingWorkspaceRestore, readLastWorkspaceRestore } from "./workspace-backup.js";
127
135
  import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
128
136
  import { createEmailSignIn, parseAllowList } from "./account-signin.js";
129
137
  import { ProviderAuthSessions } from "./provider-auth-sessions.js";
@@ -167,6 +175,16 @@ function releaseDataDirLeaseAtExit() {
167
175
  }
168
176
  }
169
177
  process.once("exit", releaseDataDirLeaseAtExit);
178
+ // Restore before constructing any long-lived config, Store, session or provider
179
+ // objects. Replacing files underneath a live Store would overwrite restored data.
180
+ let workspaceRestore = { restored: false };
181
+ if (existsSync(join(DATA_DIR, ".backups"))) {
182
+ workspaceRestore = applyPendingWorkspaceRestore(DATA_DIR);
183
+ if (!workspaceRestore.restored && !workspaceRestore.rolledBack) {
184
+ workspaceRestore = readLastWorkspaceRestore(DATA_DIR) ?? workspaceRestore;
185
+ }
186
+ }
187
+ const workspaceMaintenance = new WorkspaceBackupMaintenance();
170
188
  // Only after ensureDirs(): it performs the one-time rename of the legacy data
171
189
  // dir, which must not find a freshly created ~/.openmausbot already there.
172
190
  // Remote clients (server/request-auth.ts, server/sessions.ts): a stable identity
@@ -214,6 +232,17 @@ const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
214
232
  // Engines installed from Settings live under the data directory and win over
215
233
  // any other copy on PATH.
216
234
  registerEnginesBinDir();
235
+ // Who asked for the next turn on a thread, noted where a message comes in
236
+ // and read by the usage ledger when the turn settles. The last note stands
237
+ // until the next message: a resumed connector or a drained queued send
238
+ // still belongs to the person who wrote, and routine or peer turns are
239
+ // told apart before this is consulted.
240
+ const turnTriggers = new Map();
241
+ function noteTurnTrigger(threadId, auth) {
242
+ turnTriggers.set(threadId, auth.kind === "session"
243
+ ? { kind: "user", ...(auth.session.email ? { email: auth.session.email } : {}), label: auth.session.label }
244
+ : { kind: "owner" });
245
+ }
217
246
  const providerAuthSessions = new ProviderAuthSessions();
218
247
  await registry.load(instanceConfigs(cfg));
219
248
  const bundledSkills = loadBundledSkills();
@@ -1066,6 +1095,7 @@ function previewSystemPrompt(bot) {
1066
1095
  },
1067
1096
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
1068
1097
  { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
1098
+ { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(customMcpServers(cfg, bot.mcpServers))) : "" },
1069
1099
  { id: "browser", label: "Browser", text: caps?.browserMcp && builtInBrowserEnabled(cfg) && bot.browser !== false && bot.computer !== "off" ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
1070
1100
  { id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
1071
1101
  { id: "credential", label: "Credentials", text: agentsMounted ? CREDENTIAL_PROMPT : "" },
@@ -3236,6 +3266,28 @@ bus.subscribe((event) => {
3236
3266
  cachedInput: tokens?.cachedInput,
3237
3267
  costUsd: event.cost ?? null,
3238
3268
  });
3269
+ // and write the same figures to the month's ledger, which outlives
3270
+ // the task and answers "what did we spend, by whom" for a period
3271
+ const settledTask = store.taskByThread(bot.id, event.threadId);
3272
+ const selection = settledTask?.modelSelection ?? bot.modelSelection;
3273
+ appendUsage(DATA_DIR, {
3274
+ botId: bot.id,
3275
+ botName: bot.name,
3276
+ threadId: event.threadId,
3277
+ instanceId: selection.instanceId,
3278
+ driverKind: registry.get(selection.instanceId)?.driverKind ?? "unknown",
3279
+ model: selection.model,
3280
+ input: tokens?.input ?? 0,
3281
+ output: tokens?.output ?? 0,
3282
+ ...(typeof tokens?.cachedInput === "number" ? { cachedInput: tokens.cachedInput } : {}),
3283
+ costUsd: event.cost ?? null,
3284
+ trigger: routineRun
3285
+ ? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
3286
+ : internal
3287
+ ? { kind: "bot", ...(settledTask?.openedBy?.botId ? { botId: settledTask.openedBy.botId } : {}) }
3288
+ : turnTriggers.get(event.threadId) ?? { kind: "owner" },
3289
+ });
3290
+ noteSpend(DATA_DIR, event.cost ?? null);
3239
3291
  const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
3240
3292
  // A routine's result belongs to its reporting thread's unread state.
3241
3293
  // Its internal execution should not light up the sidebar as well.
@@ -3928,6 +3980,7 @@ async function finalScreenFrame(_botId, threadId) {
3928
3980
  }
3929
3981
  // ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
3930
3982
  async function startTurn(botId, text, opts) {
3983
+ workspaceMaintenance.assertAvailable();
3931
3984
  const profile = store.bot(botId);
3932
3985
  if (!profile)
3933
3986
  throw Object.assign(new Error("no such bot"), { status: 404 });
@@ -3956,6 +4009,9 @@ async function startTurn(botId, text, opts) {
3956
4009
  throw Object.assign(new Error(transitionError), { status: 409 });
3957
4010
  if (providerFleetReloading)
3958
4011
  throw Object.assign(new Error("provider settings are being updated — try again shortly"), { status: 409 });
4012
+ // A workspace at its monthly spend limit starts no turn of any kind: a
4013
+ // person's message, a routine, a peer hop or a webhook all stop here.
4014
+ assertWithinBudget(cfg, DATA_DIR);
3959
4015
  if (checkpointRestoreLeases.has(botId)) {
3960
4016
  throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
3961
4017
  status: 409,
@@ -4184,7 +4240,7 @@ async function startTurn(botId, text, opts) {
4184
4240
  // composio — only to a driver that can mount them. Their tools are
4185
4241
  // never pre-allowed, so every call rides the normal permission flow.
4186
4242
  if (instance.adapter.capabilities.customMcp === true) {
4187
- const custom = customMcpServers(cfg);
4243
+ const custom = customMcpServers(cfg, bot.mcpServers);
4188
4244
  if (Object.keys(custom).length)
4189
4245
  integrations.custom = custom;
4190
4246
  }
@@ -4544,6 +4600,7 @@ async function startTurn(botId, text, opts) {
4544
4600
  // gated on the integration, not the key: the hint only goes to a
4545
4601
  // bot whose driver actually mounted the tools
4546
4602
  { id: "composio", label: "Connected apps", text: integrations.composio ? COMPOSIO_PROMPT : "" },
4603
+ { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
4547
4604
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
4548
4605
  { id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
4549
4606
  { id: "credential", label: "Credentials", text: credentialPrompt },
@@ -5217,7 +5274,10 @@ const webhooks = new WebhookManager({
5217
5274
  let webhookIngress = null;
5218
5275
  let webhookIngressError = null;
5219
5276
  try {
5220
- webhookIngress = await listenWebhookIngress(webhooks, { port: WEBHOOK_PORT, publicBaseUrl: WEBHOOK_PUBLIC_URL });
5277
+ webhookIngress = await listenWebhookIngress(webhooks, {
5278
+ port: WEBHOOK_PORT, publicBaseUrl: WEBHOOK_PUBLIC_URL,
5279
+ claimRequest: () => workspaceMaintenance.request(),
5280
+ });
5221
5281
  const advertised = WEBHOOK_PUBLIC_URL ? ` (advertised as ${webhookIngress.baseUrl})` : "";
5222
5282
  console.log(`openmausbot webhook receiver on http://${webhookIngress.host}:${webhookIngress.port}${advertised}`);
5223
5283
  }
@@ -5296,6 +5356,10 @@ operation,
5296
5356
  // execution setting changes in that gap, rebuild the turn once from the
5297
5357
  // fresh bot rather than mixing a stale adapter with fresh permissions.
5298
5358
  setupRetry = 0) {
5359
+ if (workspaceMaintenance.active) {
5360
+ onDispatchError?.("A workspace backup or restore is in progress.");
5361
+ return false;
5362
+ }
5299
5363
  if (isCancelled?.())
5300
5364
  return false;
5301
5365
  if (providerFleetReloading) {
@@ -5416,7 +5480,7 @@ setupRetry = 0) {
5416
5480
  }
5417
5481
  // user-configured MCP servers: same gating as the 1:1 site above.
5418
5482
  if (instance.adapter.capabilities.customMcp === true) {
5419
- const custom = customMcpServers(cfg);
5483
+ const custom = customMcpServers(cfg, bot.mcpServers);
5420
5484
  if (Object.keys(custom).length)
5421
5485
  integrations.custom = custom;
5422
5486
  }
@@ -5629,6 +5693,7 @@ setupRetry = 0) {
5629
5693
  store.patchBot(bot.id, { soulDrift: drift.drift });
5630
5694
  }
5631
5695
  const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
5696
+ { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
5632
5697
  { id: "computer", label: "Computer", text: computerPrompt(roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
5633
5698
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
5634
5699
  { id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
@@ -7260,6 +7325,19 @@ async function perBotLocalVmCountForModeChange() {
7260
7325
  function configStatus() {
7261
7326
  return {
7262
7327
  xai: { configured: Boolean(cfg.xai?.key) },
7328
+ anthropic: { configured: Boolean(cfg.anthropic?.key) },
7329
+ // a fleet agent on this server means Settings → Workspaces has something to drive
7330
+ fleet: { available: fleetAvailable(fleetSocketPath()) },
7331
+ // what this build is entitled to, so Settings shows only what works here
7332
+ edition: (({ edition, features }) => ({ edition, features }))(editionStatus()),
7333
+ // settings, not secrets: the cap and the operator's own price list
7334
+ budgets: {
7335
+ ...(cfg.budgets?.monthlyUsd !== undefined ? { monthlyUsd: cfg.budgets.monthlyUsd } : {}),
7336
+ ...(cfg.budgets?.warnAtPercent !== undefined ? { warnAtPercent: cfg.budgets.warnAtPercent } : {}),
7337
+ },
7338
+ billing: { currency: cfg.billing?.currency ?? "USD", prices: cfg.billing?.prices ?? {} },
7339
+ // the base URL is a setting, not a secret; the key stays write-only
7340
+ openaiCompat: { configured: Boolean(cfg.openaiCompat?.key), url: cfg.openaiCompat?.url ?? "" },
7263
7341
  composio: {
7264
7342
  configured: composio.configured(cfg),
7265
7343
  mode: composio.connectionMode(cfg),
@@ -7537,6 +7615,39 @@ function readBody(req, limit = 1_000_000) {
7537
7615
  // requests from any loopback connection and any web page that DNS-rebinds
7538
7616
  // onto it. Reject non-loopback Hosts outright (defeats rebinding) and
7539
7617
  // origins outside loopback (blocks remote-web CSRF).
7618
+ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
7619
+ dataDir: DATA_DIR,
7620
+ appVersion: serverVersion(),
7621
+ readBody,
7622
+ restored: workspaceRestore,
7623
+ status: () => ({ busy: workspaceMaintenance.active, pendingRestore: workspaceMaintenance.pendingRestore }),
7624
+ authorized: (req, original) => {
7625
+ const current = resolveRequestAuth(req, {
7626
+ sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events",
7627
+ url: new URL(req.url ?? "/", `http://localhost:${PORT}`),
7628
+ loopbackMutationToken: desktopMutationToken, companionMutationToken,
7629
+ }).auth;
7630
+ return Boolean(current?.scopes.includes("admin") && current.kind === original.kind &&
7631
+ (current.kind !== "session" || (original.kind === "session" && current.session.id === original.session.id)));
7632
+ },
7633
+ exclusive: (work, keepLocked) => workspaceMaintenance.run(work, {
7634
+ idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall &&
7635
+ !routines?.isTicking && !calendarCalls?.isTicking &&
7636
+ !localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
7637
+ !localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !orphanBoxLifecycleBusyIds.size &&
7638
+ !computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
7639
+ store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !computerControl.snapshot(bot.id).held) &&
7640
+ store.groups.every((group) => !groupIsWorking(group)),
7641
+ pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); },
7642
+ resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); },
7643
+ flush: async () => {
7644
+ await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR)]);
7645
+ // With writers gated and work idle, release our WAL connection for the
7646
+ // consistent snapshot. Store reopens it lazily after maintenance.
7647
+ closeMessageDb();
7648
+ },
7649
+ }, keepLocked),
7650
+ });
7540
7651
  const handleRequest = async (req, res) => {
7541
7652
  let url;
7542
7653
  try {
@@ -7549,6 +7660,7 @@ const handleRequest = async (req, res) => {
7549
7660
  const method = req.method ?? "GET";
7550
7661
  /** scratch for route matches, shared by every `path.match` below */
7551
7662
  let m = null;
7663
+ let releaseWorkspaceRequest;
7552
7664
  try {
7553
7665
  // ── who is asking (server/request-auth.ts) ──────────────────────────
7554
7666
  // Two public routes come first: what this server is, and turning a pairing
@@ -7666,6 +7778,13 @@ const handleRequest = async (req, res) => {
7666
7778
  if (!gate.auth)
7667
7779
  return json(res, gate.status, { error: gate.error });
7668
7780
  const auth = gate.auth;
7781
+ if (await workspaceBackupRoutes(req, res, path, auth))
7782
+ return;
7783
+ // Count ordinary requests until their asynchronous handler returns, not
7784
+ // merely until the browser disconnects. A cancelled upload can still write.
7785
+ if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !isWorkspaceBackupSessionControl(method, path)) {
7786
+ releaseWorkspaceRequest = workspaceMaintenance.request();
7787
+ }
7669
7788
  // ── sessions: who am I, tickets, pairing and revocation ─────────────
7670
7789
  if (method === "GET" && path === "/api/auth/session") {
7671
7790
  return json(res, 200, auth.kind === "loopback"
@@ -10350,6 +10469,13 @@ const handleRequest = async (req, res) => {
10350
10469
  return json(res, 400, { error: "threadId must be a task id" });
10351
10470
  }
10352
10471
  const threadId = body.threadId ?? group.threadId;
10472
+ noteTurnTrigger(threadId, auth);
10473
+ try {
10474
+ assertWithinBudget(cfg, DATA_DIR);
10475
+ }
10476
+ catch (error) {
10477
+ return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
10478
+ }
10353
10479
  const ownsThread = group.dm
10354
10480
  ? group.threadId === threadId
10355
10481
  : Boolean(store.groupTaskByThread(group.id, threadId));
@@ -10829,6 +10955,27 @@ const handleRequest = async (req, res) => {
10829
10955
  return json(res, 400, { error: "composio must be true or false" });
10830
10956
  patch.composio = body.composio;
10831
10957
  }
10958
+ // Per-bot selection of app-wide MCP servers. Omitted keeps the current
10959
+ // selection; null restores all enabled servers; [] explicitly mounts none.
10960
+ let requestedMcpServers = existingBot?.mcpServers;
10961
+ if (body.mcpServers !== undefined) {
10962
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
10963
+ return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
10964
+ }
10965
+ if (body.mcpServers === null) {
10966
+ requestedMcpServers = undefined;
10967
+ }
10968
+ else if (!Array.isArray(body.mcpServers) || body.mcpServers.some((t) => typeof t !== "string" || mcpServerNameError(t) !== null)) {
10969
+ return json(res, 400, { error: "mcpServers must be a list of server names, or null" });
10970
+ }
10971
+ else {
10972
+ requestedMcpServers = [...new Set(body.mcpServers)];
10973
+ if (requestedMcpServers.length > MAX_MCP_SERVERS) {
10974
+ return json(res, 400, { error: `Select at most ${MAX_MCP_SERVERS} MCP servers.` });
10975
+ }
10976
+ }
10977
+ patch.mcpServers = requestedMcpServers;
10978
+ }
10832
10979
  // per-bot gate on the app's built-in browser
10833
10980
  if (body.browser !== undefined) {
10834
10981
  if (typeof body.browser !== "boolean")
@@ -11045,6 +11192,10 @@ const handleRequest = async (req, res) => {
11045
11192
  if (Array.isArray(patch.alwaysAllow) && patch.alwaysAllow.some((key) => !(existingBot?.alwaysAllow ?? []).includes(key))) {
11046
11193
  loosened.push("alwaysAllow");
11047
11194
  }
11195
+ if (body.mcpServers !== undefined && Array.isArray(existingBot?.mcpServers) &&
11196
+ (requestedMcpServers === undefined || requestedMcpServers.some((name) => !existingBot.mcpServers.includes(name)))) {
11197
+ loosened.push("mcpServers");
11198
+ }
11048
11199
  const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
11049
11200
  if (loosened.length && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin) {
11050
11201
  if (store.bots.some((candidate) => candidate.busy)) {
@@ -11073,6 +11224,23 @@ const handleRequest = async (req, res) => {
11073
11224
  sectionKey(existingBot?.section) !== sectionKey(section);
11074
11225
  let bot;
11075
11226
  const freshBrowserBot = store.bot(m[1]);
11227
+ // Custom servers are process configuration: a running turn cannot
11228
+ // unmount them. Recheck after any awaited runtime revocation above.
11229
+ if (body.mcpServers !== undefined) {
11230
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
11231
+ return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
11232
+ }
11233
+ if (Array.isArray(freshBrowserBot?.mcpServers) &&
11234
+ (requestedMcpServers === undefined || requestedMcpServers.some((name) => !freshBrowserBot.mcpServers.includes(name))) &&
11235
+ auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin && store.bots.some((candidate) => candidate.busy)) {
11236
+ return json(res, 409, { error: "A bot is working right now, so this change has to come from the desktop app or a paired device. Try again once every bot is idle." });
11237
+ }
11238
+ if (freshBrowserBot &&
11239
+ JSON.stringify(requestedMcpServers?.toSorted()) !== JSON.stringify(freshBrowserBot.mcpServers?.toSorted()) &&
11240
+ (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
11241
+ return json(res, 409, { error: "Stop this bot's turns before changing its MCP servers." });
11242
+ }
11243
+ }
11076
11244
  if (normalizedSelection && selectedTask) {
11077
11245
  const current = store.projectBotForTask(selectedTask.id, selectedTask.threadId);
11078
11246
  if (!current)
@@ -11814,6 +11982,15 @@ const handleRequest = async (req, res) => {
11814
11982
  // receipt after a task switch, while a genuinely new send still has to
11815
11983
  // target the task that is active now.
11816
11984
  const threadId = body.threadId ?? bot.threadId;
11985
+ noteTurnTrigger(threadId, auth);
11986
+ // The send is acknowledged before the turn starts, so a workspace at its
11987
+ // spend limit is refused here, where the person can see it.
11988
+ try {
11989
+ assertWithinBudget(cfg, DATA_DIR);
11990
+ }
11991
+ catch (error) {
11992
+ return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
11993
+ }
11817
11994
  if (!store.taskByThread(bot.id, threadId)) {
11818
11995
  return json(res, 409, { error: "the bot switched tasks before it could receive the message" });
11819
11996
  }
@@ -12589,6 +12766,39 @@ const handleRequest = async (req, res) => {
12589
12766
  }
12590
12767
  return json(res, 202, { installing: true });
12591
12768
  }
12769
+ // ── the fleet: client workspaces on this server, through the root agent ──
12770
+ // Admin scope by default plus the `admin` entitlement; the socket's own
12771
+ // permissions decide whether this workspace may drive the agent at all.
12772
+ const fleetRoute = /^\/api\/fleet(?:\/(workspaces(?:\/([a-z0-9-]+)(?:\/(users|suspend|resume))?)?|upgrade))?$/.exec(path);
12773
+ if (fleetRoute) {
12774
+ if (!entitled("admin"))
12775
+ return json(res, 403, { error: "Workspaces need an enterprise licence with the admin feature." });
12776
+ const socket = fleetSocketPath();
12777
+ if (!fleetAvailable(socket))
12778
+ return json(res, 404, { error: "No fleet agent on this server. Run `openmausbot fleet init --domain … --operator <this user>` as root." });
12779
+ const [, resource, slug, sub] = fleetRoute;
12780
+ let forward = null;
12781
+ if (method === "GET" && !resource)
12782
+ forward = { method: "GET", path: "/workspaces" };
12783
+ else if (method === "POST" && resource === "workspaces")
12784
+ forward = { method: "POST", path: "/workspaces", body: await readBody(req, 256 * 1024) };
12785
+ else if (method === "POST" && resource === "upgrade")
12786
+ forward = { method: "POST", path: "/upgrade" };
12787
+ else if (slug && method === "POST" && (sub === "users" || sub === "suspend" || sub === "resume"))
12788
+ forward = { method: "POST", path: `/workspaces/${slug}/${sub}`, ...(sub === "users" ? { body: await readBody(req, 8192) } : {}) };
12789
+ else if (slug && method === "DELETE" && !sub)
12790
+ forward = { method: "DELETE", path: `/workspaces/${slug}`, body: await readBody(req, 8192) };
12791
+ if (!forward)
12792
+ return json(res, 405, { error: "no such fleet operation" });
12793
+ res.setHeader("cache-control", "no-store");
12794
+ try {
12795
+ const reply = await fleetRequest(socket, forward.method, forward.path, forward.body);
12796
+ return json(res, reply.status, reply.body ?? {});
12797
+ }
12798
+ catch (error) {
12799
+ return json(res, 502, { error: error instanceof Error ? error.message : String(error) });
12800
+ }
12801
+ }
12592
12802
  // Which edition this server runs and why (see server/enterprise.ts). Read-only.
12593
12803
  if (method === "GET" && path === "/api/edition") {
12594
12804
  return json(res, 200, editionStatus());
@@ -12619,6 +12829,62 @@ const handleRequest = async (req, res) => {
12619
12829
  // Read-only like the inspector above: the rows were written at the
12620
12830
  // request.opened fold and in answerRequest; this only reads them back,
12621
12831
  // newest last, same order as thread events.
12832
+ // ── the usage ledger: what this workspace spent over a period ──
12833
+ // Read-only over the month files usage-ledger.ts appends at turn.completed.
12834
+ // Admin scope by default, like every route not opened to clients.
12835
+ if (method === "GET" && (path === "/api/usage" || path === "/api/usage.csv")) {
12836
+ const range = parseUsageRange(url.searchParams.get("from"), url.searchParams.get("to"));
12837
+ if (!range)
12838
+ return json(res, 400, { error: "from and to must be YYYY-MM-DD, from no later than to, at most a year apart" });
12839
+ const rows = readUsage(DATA_DIR, range);
12840
+ // The operator's price list is applied only with the billing entitlement.
12841
+ const prices = entitled("billing") && cfg.billing?.prices && Object.keys(cfg.billing.prices).length ? cfg.billing.prices : null;
12842
+ if (path === "/api/usage.csv") {
12843
+ const stamp = (date) => date.toISOString().slice(0, 10);
12844
+ res.writeHead(200, {
12845
+ "content-type": "text/csv; charset=utf-8",
12846
+ "content-disposition": `attachment; filename="usage-${stamp(range.from)}-${stamp(range.to)}.csv"`,
12847
+ "cache-control": "no-store",
12848
+ });
12849
+ res.end(usageCsv(rows, prices));
12850
+ return;
12851
+ }
12852
+ const requested = url.searchParams.get("groupBy") ?? "bot";
12853
+ if (!USAGE_GROUPINGS.includes(requested)) {
12854
+ return json(res, 400, { error: `groupBy must be one of ${USAGE_GROUPINGS.join(", ")}` });
12855
+ }
12856
+ const groupBy = requested;
12857
+ res.setHeader("cache-control", "no-store");
12858
+ return json(res, 200, {
12859
+ from: range.from.toISOString(),
12860
+ to: range.to.toISOString(),
12861
+ groupBy,
12862
+ ...summarizeUsage(rows, groupBy, prices),
12863
+ budget: spendState(cfg, DATA_DIR),
12864
+ billing: prices ? { currency: cfg.billing?.currency ?? "USD" } : null,
12865
+ });
12866
+ }
12867
+ // ── provider key check: does a pasted or saved key open the provider's door ──
12868
+ // One read-only request from this server; the verdict never carries the
12869
+ // key. Admin scope by default, like every route not opened to clients.
12870
+ if (method === "POST" && path === "/api/keys/test") {
12871
+ const body = await readBody(req, 8192);
12872
+ const provider = body?.provider;
12873
+ if (!PROVIDER_KEY_KINDS.includes(provider)) {
12874
+ return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
12875
+ }
12876
+ const kind = provider;
12877
+ const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : cfg.xai;
12878
+ const pasted = typeof body?.key === "string" ? body.key.trim() : "";
12879
+ const key = pasted || saved?.key || "";
12880
+ if (!key)
12881
+ return json(res, 400, { error: "No key to test. Paste one or save one first." });
12882
+ if (key.length > 512)
12883
+ return json(res, 400, { error: "That does not look like an API key." });
12884
+ const url = typeof body?.url === "string" && body.url.trim() ? body.url.trim() : saved?.url;
12885
+ res.setHeader("cache-control", "no-store");
12886
+ return json(res, 200, await checkProviderKey({ provider: kind, key, url }));
12887
+ }
12622
12888
  if (method === "GET" && path === "/api/decisions") {
12623
12889
  const rawLimit = url.searchParams.get("limit");
12624
12890
  const parsedLimit = rawLimit === null ? undefined : Number(rawLimit);
@@ -12941,6 +13207,42 @@ const handleRequest = async (req, res) => {
12941
13207
  mcpConfigBusy = false;
12942
13208
  }
12943
13209
  }
13210
+ // Paste-to-add: the {"mcpServers": {...}} block every other agent tool
13211
+ // writes. Same rules as POST: disabled until explicitly enabled.
13212
+ if (method === "POST" && path === "/api/mcp/servers/import") {
13213
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
13214
+ return json(res, 415, { error: "content-type must be application/json" });
13215
+ }
13216
+ if (mcpConfigBusy)
13217
+ return json(res, 409, { error: "MCP servers are already being updated." });
13218
+ mcpConfigBusy = true;
13219
+ try {
13220
+ const body = await readBody(req);
13221
+ const text = typeof body?.json === "string" ? body.json : "";
13222
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
13223
+ return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
13224
+ }
13225
+ if (!text.trim())
13226
+ return json(res, 400, { error: "Paste the JSON block first." });
13227
+ const parsed = parseMcpServersImport(text);
13228
+ if (!parsed.ok)
13229
+ return json(res, 400, { error: parsed.error });
13230
+ const current = cfg.mcpServers ?? {};
13231
+ const names = Object.keys(parsed.servers);
13232
+ const taken = names.filter((name) => Object.hasOwn(current, name));
13233
+ if (taken.length) {
13234
+ return json(res, 409, { error: `Already added: ${taken.join(", ")}. Remove or rename ${taken.length === 1 ? "it" : "them"} first.` });
13235
+ }
13236
+ if (Object.keys(current).length + names.length > MAX_MCP_SERVERS) {
13237
+ return json(res, 400, { error: `You can add at most ${MAX_MCP_SERVERS} MCP servers.` });
13238
+ }
13239
+ persistMcpServers({ ...current, ...parsed.servers });
13240
+ return json(res, 201, { ...mcpServerResponse(), added: names });
13241
+ }
13242
+ finally {
13243
+ mcpConfigBusy = false;
13244
+ }
13245
+ }
12944
13246
  const mcpServerRoute = /^\/api\/mcp\/servers\/([a-z][a-z0-9_-]{0,31})$/.exec(path);
12945
13247
  if (mcpServerRoute && ["PUT", "PATCH", "DELETE"].includes(method)) {
12946
13248
  if (method !== "DELETE" && !String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
@@ -13806,6 +14108,9 @@ const handleRequest = async (req, res) => {
13806
14108
  const status = e?.status ?? 500;
13807
14109
  return json(res, status, { error: e instanceof Error ? e.message : String(e) });
13808
14110
  }
14111
+ finally {
14112
+ releaseWorkspaceRequest?.();
14113
+ }
13809
14114
  };
13810
14115
  const server = createServer(handleRequest);
13811
14116
  calendarCalls.start();
@@ -13887,11 +14192,14 @@ const gracefulShutdown = createGracefulShutdown({
13887
14192
  },
13888
14193
  () => flushAllProfileHistory(),
13889
14194
  () => flushAllMemoryJournals(),
14195
+ () => flushUsageLedger(DATA_DIR),
14196
+ () => flushDecisionLog(DATA_DIR),
13890
14197
  ],
13891
14198
  // Cleanup jobs run concurrently. Release only after they settle (or reach
13892
14199
  // the shutdown deadline), immediately before the process exits, so no new
13893
14200
  // server can overlap with a still-mutating old one.
13894
14201
  exit: (code) => {
14202
+ closeMessageDb();
13895
14203
  releaseDataDirLeaseAtExit();
13896
14204
  process.exit(code);
13897
14205
  },
@@ -24,6 +24,10 @@ export function gatedLocalComputer(connection, control) {
24
24
  args: ["--experimental-strip-types", SPAWNED_PROXIES.localComputer],
25
25
  env: {
26
26
  ...connection.env,
27
+ // In a packaged desktop process execPath is Electron, not node. Without
28
+ // this the MCP client relaunches OMB, whose single-instance handler
29
+ // focuses the user's window, instead of starting the headless gate.
30
+ ELECTRON_RUN_AS_NODE: "1",
27
31
  OMB_CUA_COMMAND: connection.command,
28
32
  OMB_CUA_ARGS: JSON.stringify(connection.args),
29
33
  OMB_CONTROL_URL: control.url,
@@ -107,9 +107,8 @@ export function parseMcpServerMutation(name, raw, existing) {
107
107
  command: parsed.data.command,
108
108
  args: parsed.data.args ?? [],
109
109
  env,
110
- // A newly added command is inert until the person has tested and
111
- // explicitly enabled it. Existing file-authored entries keep today's
112
- // enabled-by-default behavior through parseStoredMcpServer.
110
+ // A newly added command is inert until explicitly enabled. File-authored
111
+ // entries keep enabled-by-default behavior through parseStoredMcpServer.
113
112
  enabled: existing ? (parsed.data.enabled ?? existing.enabled) : false,
114
113
  },
115
114
  };
@@ -128,3 +127,72 @@ export function listMcpServers(raw) {
128
127
  }];
129
128
  });
130
129
  }
130
+ /**
131
+ * Turn a pasted config block into servers this registry can store. Accepts
132
+ * the {"mcpServers": {...}} shape Claude Code, Cursor and Claude Desktop
133
+ * write, a bare {name: entry} map, or a single {name, command, ...} entry.
134
+ * Names are slugged into the registry's format; every entry goes through the
135
+ * same rules as the form and lands disabled until explicitly enabled.
136
+ */
137
+ export function parseMcpServersImport(text) {
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(text);
141
+ }
142
+ catch {
143
+ return { ok: false, error: "That is not valid JSON." };
144
+ }
145
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
146
+ return { ok: false, error: 'Expected an object like {"mcpServers": {"name": {"command": "npx", "args": [...]}}}.' };
147
+ }
148
+ const root = parsed;
149
+ let entries;
150
+ if (root.mcpServers && typeof root.mcpServers === "object" && !Array.isArray(root.mcpServers)) {
151
+ entries = root.mcpServers;
152
+ }
153
+ else if (typeof root.command === "string" || typeof root.url === "string") {
154
+ const name = typeof root.name === "string" ? root.name : "";
155
+ if (!name)
156
+ return { ok: false, error: 'A single server needs a "name".' };
157
+ const { name: _omit, ...rest } = root;
158
+ entries = { [name]: rest };
159
+ }
160
+ else {
161
+ entries = root;
162
+ }
163
+ const names = Object.keys(entries);
164
+ if (names.length === 0)
165
+ return { ok: false, error: "No servers found in that JSON." };
166
+ if (names.length > MAX_MCP_SERVERS) {
167
+ return { ok: false, error: `Import at most ${MAX_MCP_SERVERS} servers at once.` };
168
+ }
169
+ const servers = {};
170
+ for (const rawName of names) {
171
+ const entry = entries[rawName];
172
+ const name = slugMcpName(rawName);
173
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
174
+ return { ok: false, error: `"${rawName}" is not a server entry.` };
175
+ }
176
+ const record = entry;
177
+ if (typeof record.url === "string" || record.type === "http" || record.type === "sse") {
178
+ return { ok: false, error: `"${rawName}" is a remote (url) server; only command servers can be added here so far.` };
179
+ }
180
+ // The command is an executable path, not a shell line; spaces are valid
181
+ // in paths on every supported platform. The probe reports missing files.
182
+ if (Object.hasOwn(servers, name))
183
+ return { ok: false, error: `"${rawName}" appears twice.` };
184
+ const result = parseMcpServerMutation(name, {
185
+ command: record.command,
186
+ args: record.args,
187
+ env: record.env,
188
+ });
189
+ if (!result.ok)
190
+ return { ok: false, error: `"${rawName}": ${result.error}` };
191
+ servers[name] = result.server;
192
+ }
193
+ return { ok: true, servers };
194
+ }
195
+ function slugMcpName(raw) {
196
+ const slug = raw.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
197
+ return /^[a-z]/.test(slug) ? slug : slug ? `mcp-${slug}`.slice(0, 32) : "";
198
+ }