openmausbot 0.1.71 → 0.1.73
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.
- package/dist/assets/index-BLJ7byG3.js +296 -0
- package/dist/assets/{index-CJtyBonr.js → index-BYbK7ber.js} +1 -1
- package/dist/assets/index-De2TRLxu.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/listener.js +12 -2
- package/dist-server/container-mcp.js +26 -2
- package/dist-server/drivers/agents-proxy.js +2 -2
- package/dist-server/index.js +7603 -2509
- package/dist-server/local-computer.js +4 -0
- package/dist-server/openmausbot.js +1639 -581
- package/dist-server/pair-cli.js +1639 -581
- package/dist-server/server/bot-overview.js +28 -0
- package/dist-server/server/browser-engine.js +2 -1
- package/dist-server/server/calendar-calls.js +1 -0
- package/dist-server/server/cli.js +102 -1
- package/dist-server/server/config.js +57 -5
- package/dist-server/server/drivers/agents-proxy.js +2 -2
- package/dist-server/server/drivers/claude.js +14 -7
- package/dist-server/server/drivers/codex.js +64 -16
- package/dist-server/server/fleet-agent.js +161 -0
- package/dist-server/server/fleet-cli.js +225 -0
- package/dist-server/server/fleet-client.js +45 -0
- package/dist-server/server/fleet.js +410 -0
- package/dist-server/server/index.js +340 -15
- package/dist-server/server/local-computer.js +4 -0
- package/dist-server/server/mcp-registry.js +71 -3
- package/dist-server/server/prices.js +16 -0
- package/dist-server/server/provider-auth-sessions.js +3 -0
- package/dist-server/server/provider-key-check.js +66 -0
- package/dist-server/server/routines.js +1 -0
- package/dist-server/server/skill-learn.js +15 -1
- package/dist-server/server/spend.js +61 -0
- package/dist-server/server/system-prompt.js +15 -5
- package/dist-server/server/tailscale.js +2 -2
- package/dist-server/server/usage-ledger.js +239 -0
- package/dist-server/server/vps-computer.js +81 -12
- package/dist-server/server/webhook-ingress.js +7 -2
- package/dist-server/server/workspace-backup-http.js +264 -0
- package/dist-server/server/workspace-backup-maintenance.js +40 -0
- package/dist-server/server/workspace-backup-policy.js +28 -0
- package/dist-server/server/workspace-backup.js +1022 -0
- package/dist-server/shared/learn-request.js +6 -0
- package/dist-server/shared/workspace-backup-client.js +15 -0
- package/dist-server/shared/workspace-backup.js +1 -0
- package/dist-server/vps-container-mcp.js +26 -2
- package/package.json +1 -1
- package/dist/assets/index-CYLdYtYp.css +0 -1
- 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 : "" },
|
|
@@ -2510,6 +2540,9 @@ let localVmProvisionBusy = false;
|
|
|
2510
2540
|
let localVmModeChangeBusy = false;
|
|
2511
2541
|
const activeVpsThreads = new Map();
|
|
2512
2542
|
const boxLifecycleBusyBots = new Set();
|
|
2543
|
+
// A refresh is a reader, not a lifecycle change. Keep its reservation until
|
|
2544
|
+
// the provider settles even if the HTTP client leaves, and share it on retry.
|
|
2545
|
+
const vpsPreviewRequests = new Map();
|
|
2513
2546
|
const orphanBoxLifecycleBusyIds = new Set();
|
|
2514
2547
|
const boxInventoryRequestsBusyIds = new Set();
|
|
2515
2548
|
const computerProviderConfigTransitions = new Set();
|
|
@@ -2558,7 +2591,7 @@ function providerOperationConflict(provider) {
|
|
|
2558
2591
|
if (managedBoxOwners().some((owner) => owner.inUse)) {
|
|
2559
2592
|
return `stop active bot work and computer control before changing ${provider === "box" ? "the Box account" : "the VPS connection"}`;
|
|
2560
2593
|
}
|
|
2561
|
-
if (boxLifecycleBusyBots.size > 0) {
|
|
2594
|
+
if (boxLifecycleBusyBots.size > 0 || vpsPreviewRequests.size > 0) {
|
|
2562
2595
|
return "wait for cloud computer actions to finish before changing provider settings";
|
|
2563
2596
|
}
|
|
2564
2597
|
if (provider === "box") {
|
|
@@ -2612,13 +2645,17 @@ function claimManagedBoxMutation(instance) {
|
|
|
2612
2645
|
}
|
|
2613
2646
|
return claimBotComputerLifecycle(ownerBotId);
|
|
2614
2647
|
}
|
|
2615
|
-
/** One synchronous lane for
|
|
2648
|
+
/** One synchronous lane for cloud lifecycle consumers. Both Settings and
|
|
2616
2649
|
* bot-scoped actions use it, so whichever operation starts first excludes the
|
|
2617
|
-
* other instead of relying on a stale check made before a provider await.
|
|
2618
|
-
|
|
2650
|
+
* other instead of relying on a stale check made before a provider await.
|
|
2651
|
+
* Only opening an existing VPS viewer may coexist with its pending preview. */
|
|
2652
|
+
function claimBotComputerLifecycle(botId, allowPreview = false) {
|
|
2619
2653
|
if (boxLifecycleBusyBots.has(botId)) {
|
|
2620
2654
|
throw Object.assign(new Error("this bot's cloud computer is being changed — wait for it to finish"), { status: 409 });
|
|
2621
2655
|
}
|
|
2656
|
+
if (!allowPreview && vpsPreviewRequests.has(botId)) {
|
|
2657
|
+
throw Object.assign(new Error("a screen preview is still refreshing — wait before changing this computer"), { status: 409 });
|
|
2658
|
+
}
|
|
2622
2659
|
boxLifecycleBusyBots.add(botId);
|
|
2623
2660
|
return () => boxLifecycleBusyBots.delete(botId);
|
|
2624
2661
|
}
|
|
@@ -3236,6 +3273,28 @@ bus.subscribe((event) => {
|
|
|
3236
3273
|
cachedInput: tokens?.cachedInput,
|
|
3237
3274
|
costUsd: event.cost ?? null,
|
|
3238
3275
|
});
|
|
3276
|
+
// and write the same figures to the month's ledger, which outlives
|
|
3277
|
+
// the task and answers "what did we spend, by whom" for a period
|
|
3278
|
+
const settledTask = store.taskByThread(bot.id, event.threadId);
|
|
3279
|
+
const selection = settledTask?.modelSelection ?? bot.modelSelection;
|
|
3280
|
+
appendUsage(DATA_DIR, {
|
|
3281
|
+
botId: bot.id,
|
|
3282
|
+
botName: bot.name,
|
|
3283
|
+
threadId: event.threadId,
|
|
3284
|
+
instanceId: selection.instanceId,
|
|
3285
|
+
driverKind: registry.get(selection.instanceId)?.driverKind ?? "unknown",
|
|
3286
|
+
model: selection.model,
|
|
3287
|
+
input: tokens?.input ?? 0,
|
|
3288
|
+
output: tokens?.output ?? 0,
|
|
3289
|
+
...(typeof tokens?.cachedInput === "number" ? { cachedInput: tokens.cachedInput } : {}),
|
|
3290
|
+
costUsd: event.cost ?? null,
|
|
3291
|
+
trigger: routineRun
|
|
3292
|
+
? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
|
|
3293
|
+
: internal
|
|
3294
|
+
? { kind: "bot", ...(settledTask?.openedBy?.botId ? { botId: settledTask.openedBy.botId } : {}) }
|
|
3295
|
+
: turnTriggers.get(event.threadId) ?? { kind: "owner" },
|
|
3296
|
+
});
|
|
3297
|
+
noteSpend(DATA_DIR, event.cost ?? null);
|
|
3239
3298
|
const routineReportThread = routineRun ? routineSourceThread(routineRun) : null;
|
|
3240
3299
|
// A routine's result belongs to its reporting thread's unread state.
|
|
3241
3300
|
// Its internal execution should not light up the sidebar as well.
|
|
@@ -3928,6 +3987,7 @@ async function finalScreenFrame(_botId, threadId) {
|
|
|
3928
3987
|
}
|
|
3929
3988
|
// ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
|
|
3930
3989
|
async function startTurn(botId, text, opts) {
|
|
3990
|
+
workspaceMaintenance.assertAvailable();
|
|
3931
3991
|
const profile = store.bot(botId);
|
|
3932
3992
|
if (!profile)
|
|
3933
3993
|
throw Object.assign(new Error("no such bot"), { status: 404 });
|
|
@@ -3956,6 +4016,9 @@ async function startTurn(botId, text, opts) {
|
|
|
3956
4016
|
throw Object.assign(new Error(transitionError), { status: 409 });
|
|
3957
4017
|
if (providerFleetReloading)
|
|
3958
4018
|
throw Object.assign(new Error("provider settings are being updated — try again shortly"), { status: 409 });
|
|
4019
|
+
// A workspace at its monthly spend limit starts no turn of any kind: a
|
|
4020
|
+
// person's message, a routine, a peer hop or a webhook all stop here.
|
|
4021
|
+
assertWithinBudget(cfg, DATA_DIR);
|
|
3959
4022
|
if (checkpointRestoreLeases.has(botId)) {
|
|
3960
4023
|
throw Object.assign(new Error("this bot's project files are being restored — wait for the restore to finish"), {
|
|
3961
4024
|
status: 409,
|
|
@@ -4184,7 +4247,7 @@ async function startTurn(botId, text, opts) {
|
|
|
4184
4247
|
// composio — only to a driver that can mount them. Their tools are
|
|
4185
4248
|
// never pre-allowed, so every call rides the normal permission flow.
|
|
4186
4249
|
if (instance.adapter.capabilities.customMcp === true) {
|
|
4187
|
-
const custom = customMcpServers(cfg);
|
|
4250
|
+
const custom = customMcpServers(cfg, bot.mcpServers);
|
|
4188
4251
|
if (Object.keys(custom).length)
|
|
4189
4252
|
integrations.custom = custom;
|
|
4190
4253
|
}
|
|
@@ -4544,6 +4607,7 @@ async function startTurn(botId, text, opts) {
|
|
|
4544
4607
|
// gated on the integration, not the key: the hint only goes to a
|
|
4545
4608
|
// bot whose driver actually mounted the tools
|
|
4546
4609
|
{ id: "composio", label: "Connected apps", text: integrations.composio ? COMPOSIO_PROMPT : "" },
|
|
4610
|
+
{ id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
|
|
4547
4611
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
4548
4612
|
{ id: "coordination", label: "Team", text: coordinationPrompt ? ` ${coordinationPrompt}` : "" },
|
|
4549
4613
|
{ id: "credential", label: "Credentials", text: credentialPrompt },
|
|
@@ -5217,7 +5281,10 @@ const webhooks = new WebhookManager({
|
|
|
5217
5281
|
let webhookIngress = null;
|
|
5218
5282
|
let webhookIngressError = null;
|
|
5219
5283
|
try {
|
|
5220
|
-
webhookIngress = await listenWebhookIngress(webhooks, {
|
|
5284
|
+
webhookIngress = await listenWebhookIngress(webhooks, {
|
|
5285
|
+
port: WEBHOOK_PORT, publicBaseUrl: WEBHOOK_PUBLIC_URL,
|
|
5286
|
+
claimRequest: () => workspaceMaintenance.request(),
|
|
5287
|
+
});
|
|
5221
5288
|
const advertised = WEBHOOK_PUBLIC_URL ? ` (advertised as ${webhookIngress.baseUrl})` : "";
|
|
5222
5289
|
console.log(`openmausbot webhook receiver on http://${webhookIngress.host}:${webhookIngress.port}${advertised}`);
|
|
5223
5290
|
}
|
|
@@ -5296,6 +5363,10 @@ operation,
|
|
|
5296
5363
|
// execution setting changes in that gap, rebuild the turn once from the
|
|
5297
5364
|
// fresh bot rather than mixing a stale adapter with fresh permissions.
|
|
5298
5365
|
setupRetry = 0) {
|
|
5366
|
+
if (workspaceMaintenance.active) {
|
|
5367
|
+
onDispatchError?.("A workspace backup or restore is in progress.");
|
|
5368
|
+
return false;
|
|
5369
|
+
}
|
|
5299
5370
|
if (isCancelled?.())
|
|
5300
5371
|
return false;
|
|
5301
5372
|
if (providerFleetReloading) {
|
|
@@ -5416,7 +5487,7 @@ setupRetry = 0) {
|
|
|
5416
5487
|
}
|
|
5417
5488
|
// user-configured MCP servers: same gating as the 1:1 site above.
|
|
5418
5489
|
if (instance.adapter.capabilities.customMcp === true) {
|
|
5419
|
-
const custom = customMcpServers(cfg);
|
|
5490
|
+
const custom = customMcpServers(cfg, bot.mcpServers);
|
|
5420
5491
|
if (Object.keys(custom).length)
|
|
5421
5492
|
integrations.custom = custom;
|
|
5422
5493
|
}
|
|
@@ -5629,6 +5700,7 @@ setupRetry = 0) {
|
|
|
5629
5700
|
store.patchBot(bot.id, { soulDrift: drift.drift });
|
|
5630
5701
|
}
|
|
5631
5702
|
const roomSystem = buildSystemPrompt(system, store.bot(bot.id)?.soul ?? bot.soul ?? "", [
|
|
5703
|
+
{ id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
|
|
5632
5704
|
{ id: "computer", label: "Computer", text: computerPrompt(roomVmTarget ? localVmMode(cfg) === "per-bot" ? "vm-private" : "vm-shared" : null) },
|
|
5633
5705
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
5634
5706
|
{ id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
@@ -7260,6 +7332,19 @@ async function perBotLocalVmCountForModeChange() {
|
|
|
7260
7332
|
function configStatus() {
|
|
7261
7333
|
return {
|
|
7262
7334
|
xai: { configured: Boolean(cfg.xai?.key) },
|
|
7335
|
+
anthropic: { configured: Boolean(cfg.anthropic?.key) },
|
|
7336
|
+
// a fleet agent on this server means Settings → Workspaces has something to drive
|
|
7337
|
+
fleet: { available: fleetAvailable(fleetSocketPath()) },
|
|
7338
|
+
// what this build is entitled to, so Settings shows only what works here
|
|
7339
|
+
edition: (({ edition, features }) => ({ edition, features }))(editionStatus()),
|
|
7340
|
+
// settings, not secrets: the cap and the operator's own price list
|
|
7341
|
+
budgets: {
|
|
7342
|
+
...(cfg.budgets?.monthlyUsd !== undefined ? { monthlyUsd: cfg.budgets.monthlyUsd } : {}),
|
|
7343
|
+
...(cfg.budgets?.warnAtPercent !== undefined ? { warnAtPercent: cfg.budgets.warnAtPercent } : {}),
|
|
7344
|
+
},
|
|
7345
|
+
billing: { currency: cfg.billing?.currency ?? "USD", prices: cfg.billing?.prices ?? {} },
|
|
7346
|
+
// the base URL is a setting, not a secret; the key stays write-only
|
|
7347
|
+
openaiCompat: { configured: Boolean(cfg.openaiCompat?.key), url: cfg.openaiCompat?.url ?? "" },
|
|
7263
7348
|
composio: {
|
|
7264
7349
|
configured: composio.configured(cfg),
|
|
7265
7350
|
mode: composio.connectionMode(cfg),
|
|
@@ -7537,6 +7622,39 @@ function readBody(req, limit = 1_000_000) {
|
|
|
7537
7622
|
// requests from any loopback connection and any web page that DNS-rebinds
|
|
7538
7623
|
// onto it. Reject non-loopback Hosts outright (defeats rebinding) and
|
|
7539
7624
|
// origins outside loopback (blocks remote-web CSRF).
|
|
7625
|
+
const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
7626
|
+
dataDir: DATA_DIR,
|
|
7627
|
+
appVersion: serverVersion(),
|
|
7628
|
+
readBody,
|
|
7629
|
+
restored: workspaceRestore,
|
|
7630
|
+
status: () => ({ busy: workspaceMaintenance.active, pendingRestore: workspaceMaintenance.pendingRestore }),
|
|
7631
|
+
authorized: (req, original) => {
|
|
7632
|
+
const current = resolveRequestAuth(req, {
|
|
7633
|
+
sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events",
|
|
7634
|
+
url: new URL(req.url ?? "/", `http://localhost:${PORT}`),
|
|
7635
|
+
loopbackMutationToken: desktopMutationToken, companionMutationToken,
|
|
7636
|
+
}).auth;
|
|
7637
|
+
return Boolean(current?.scopes.includes("admin") && current.kind === original.kind &&
|
|
7638
|
+
(current.kind !== "session" || (original.kind === "session" && current.session.id === original.session.id)));
|
|
7639
|
+
},
|
|
7640
|
+
exclusive: (work, keepLocked) => workspaceMaintenance.run(work, {
|
|
7641
|
+
idle: () => !providerFleetReloading && !providerAuthSessions.active && !browserEngineInstall &&
|
|
7642
|
+
!routines?.isTicking && !calendarCalls?.isTicking &&
|
|
7643
|
+
!localVmImageBusy && !localVmProvisionBusy && !localVmModeChangeBusy &&
|
|
7644
|
+
!localVmLifecycleBusy.size && !boxLifecycleBusyBots.size && !vpsPreviewRequests.size && !orphanBoxLifecycleBusyIds.size &&
|
|
7645
|
+
!computerProviderConfigTransitions.size && !checkpointRestoreLeases.size &&
|
|
7646
|
+
store.bots.every((bot) => !botHasActiveTurn(bot.id) && !routines?.activeRunForBot(bot.id) && !computerControl.snapshot(bot.id).held) &&
|
|
7647
|
+
store.groups.every((group) => !groupIsWorking(group)),
|
|
7648
|
+
pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); },
|
|
7649
|
+
resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); },
|
|
7650
|
+
flush: async () => {
|
|
7651
|
+
await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR)]);
|
|
7652
|
+
// With writers gated and work idle, release our WAL connection for the
|
|
7653
|
+
// consistent snapshot. Store reopens it lazily after maintenance.
|
|
7654
|
+
closeMessageDb();
|
|
7655
|
+
},
|
|
7656
|
+
}, keepLocked),
|
|
7657
|
+
});
|
|
7540
7658
|
const handleRequest = async (req, res) => {
|
|
7541
7659
|
let url;
|
|
7542
7660
|
try {
|
|
@@ -7549,6 +7667,7 @@ const handleRequest = async (req, res) => {
|
|
|
7549
7667
|
const method = req.method ?? "GET";
|
|
7550
7668
|
/** scratch for route matches, shared by every `path.match` below */
|
|
7551
7669
|
let m = null;
|
|
7670
|
+
let releaseWorkspaceRequest;
|
|
7552
7671
|
try {
|
|
7553
7672
|
// ── who is asking (server/request-auth.ts) ──────────────────────────
|
|
7554
7673
|
// Two public routes come first: what this server is, and turning a pairing
|
|
@@ -7666,6 +7785,13 @@ const handleRequest = async (req, res) => {
|
|
|
7666
7785
|
if (!gate.auth)
|
|
7667
7786
|
return json(res, gate.status, { error: gate.error });
|
|
7668
7787
|
const auth = gate.auth;
|
|
7788
|
+
if (await workspaceBackupRoutes(req, res, path, auth))
|
|
7789
|
+
return;
|
|
7790
|
+
// Count ordinary requests until their asynchronous handler returns, not
|
|
7791
|
+
// merely until the browser disconnects. A cancelled upload can still write.
|
|
7792
|
+
if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !isWorkspaceBackupSessionControl(method, path)) {
|
|
7793
|
+
releaseWorkspaceRequest = workspaceMaintenance.request();
|
|
7794
|
+
}
|
|
7669
7795
|
// ── sessions: who am I, tickets, pairing and revocation ─────────────
|
|
7670
7796
|
if (method === "GET" && path === "/api/auth/session") {
|
|
7671
7797
|
return json(res, 200, auth.kind === "loopback"
|
|
@@ -10350,6 +10476,13 @@ const handleRequest = async (req, res) => {
|
|
|
10350
10476
|
return json(res, 400, { error: "threadId must be a task id" });
|
|
10351
10477
|
}
|
|
10352
10478
|
const threadId = body.threadId ?? group.threadId;
|
|
10479
|
+
noteTurnTrigger(threadId, auth);
|
|
10480
|
+
try {
|
|
10481
|
+
assertWithinBudget(cfg, DATA_DIR);
|
|
10482
|
+
}
|
|
10483
|
+
catch (error) {
|
|
10484
|
+
return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
|
|
10485
|
+
}
|
|
10353
10486
|
const ownsThread = group.dm
|
|
10354
10487
|
? group.threadId === threadId
|
|
10355
10488
|
: Boolean(store.groupTaskByThread(group.id, threadId));
|
|
@@ -10829,6 +10962,27 @@ const handleRequest = async (req, res) => {
|
|
|
10829
10962
|
return json(res, 400, { error: "composio must be true or false" });
|
|
10830
10963
|
patch.composio = body.composio;
|
|
10831
10964
|
}
|
|
10965
|
+
// Per-bot selection of app-wide MCP servers. Omitted keeps the current
|
|
10966
|
+
// selection; null restores all enabled servers; [] explicitly mounts none.
|
|
10967
|
+
let requestedMcpServers = existingBot?.mcpServers;
|
|
10968
|
+
if (body.mcpServers !== undefined) {
|
|
10969
|
+
if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
10970
|
+
return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
|
|
10971
|
+
}
|
|
10972
|
+
if (body.mcpServers === null) {
|
|
10973
|
+
requestedMcpServers = undefined;
|
|
10974
|
+
}
|
|
10975
|
+
else if (!Array.isArray(body.mcpServers) || body.mcpServers.some((t) => typeof t !== "string" || mcpServerNameError(t) !== null)) {
|
|
10976
|
+
return json(res, 400, { error: "mcpServers must be a list of server names, or null" });
|
|
10977
|
+
}
|
|
10978
|
+
else {
|
|
10979
|
+
requestedMcpServers = [...new Set(body.mcpServers)];
|
|
10980
|
+
if (requestedMcpServers.length > MAX_MCP_SERVERS) {
|
|
10981
|
+
return json(res, 400, { error: `Select at most ${MAX_MCP_SERVERS} MCP servers.` });
|
|
10982
|
+
}
|
|
10983
|
+
}
|
|
10984
|
+
patch.mcpServers = requestedMcpServers;
|
|
10985
|
+
}
|
|
10832
10986
|
// per-bot gate on the app's built-in browser
|
|
10833
10987
|
if (body.browser !== undefined) {
|
|
10834
10988
|
if (typeof body.browser !== "boolean")
|
|
@@ -11045,6 +11199,10 @@ const handleRequest = async (req, res) => {
|
|
|
11045
11199
|
if (Array.isArray(patch.alwaysAllow) && patch.alwaysAllow.some((key) => !(existingBot?.alwaysAllow ?? []).includes(key))) {
|
|
11046
11200
|
loosened.push("alwaysAllow");
|
|
11047
11201
|
}
|
|
11202
|
+
if (body.mcpServers !== undefined && Array.isArray(existingBot?.mcpServers) &&
|
|
11203
|
+
(requestedMcpServers === undefined || requestedMcpServers.some((name) => !existingBot.mcpServers.includes(name)))) {
|
|
11204
|
+
loosened.push("mcpServers");
|
|
11205
|
+
}
|
|
11048
11206
|
const browserOrigin = typeof req.headers.origin === "string" && req.headers.origin.trim() !== "";
|
|
11049
11207
|
if (loosened.length && auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin) {
|
|
11050
11208
|
if (store.bots.some((candidate) => candidate.busy)) {
|
|
@@ -11073,6 +11231,23 @@ const handleRequest = async (req, res) => {
|
|
|
11073
11231
|
sectionKey(existingBot?.section) !== sectionKey(section);
|
|
11074
11232
|
let bot;
|
|
11075
11233
|
const freshBrowserBot = store.bot(m[1]);
|
|
11234
|
+
// Custom servers are process configuration: a running turn cannot
|
|
11235
|
+
// unmount them. Recheck after any awaited runtime revocation above.
|
|
11236
|
+
if (body.mcpServers !== undefined) {
|
|
11237
|
+
if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
11238
|
+
return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
|
|
11239
|
+
}
|
|
11240
|
+
if (Array.isArray(freshBrowserBot?.mcpServers) &&
|
|
11241
|
+
(requestedMcpServers === undefined || requestedMcpServers.some((name) => !freshBrowserBot.mcpServers.includes(name))) &&
|
|
11242
|
+
auth.kind === "loopback" && !DESKTOP_MANAGED && !browserOrigin && store.bots.some((candidate) => candidate.busy)) {
|
|
11243
|
+
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." });
|
|
11244
|
+
}
|
|
11245
|
+
if (freshBrowserBot &&
|
|
11246
|
+
JSON.stringify(requestedMcpServers?.toSorted()) !== JSON.stringify(freshBrowserBot.mcpServers?.toSorted()) &&
|
|
11247
|
+
(freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
|
|
11248
|
+
return json(res, 409, { error: "Stop this bot's turns before changing its MCP servers." });
|
|
11249
|
+
}
|
|
11250
|
+
}
|
|
11076
11251
|
if (normalizedSelection && selectedTask) {
|
|
11077
11252
|
const current = store.projectBotForTask(selectedTask.id, selectedTask.threadId);
|
|
11078
11253
|
if (!current)
|
|
@@ -11814,6 +11989,15 @@ const handleRequest = async (req, res) => {
|
|
|
11814
11989
|
// receipt after a task switch, while a genuinely new send still has to
|
|
11815
11990
|
// target the task that is active now.
|
|
11816
11991
|
const threadId = body.threadId ?? bot.threadId;
|
|
11992
|
+
noteTurnTrigger(threadId, auth);
|
|
11993
|
+
// The send is acknowledged before the turn starts, so a workspace at its
|
|
11994
|
+
// spend limit is refused here, where the person can see it.
|
|
11995
|
+
try {
|
|
11996
|
+
assertWithinBudget(cfg, DATA_DIR);
|
|
11997
|
+
}
|
|
11998
|
+
catch (error) {
|
|
11999
|
+
return json(res, 409, { error: error instanceof Error ? error.message : String(error), code: "spend_cap" });
|
|
12000
|
+
}
|
|
11817
12001
|
if (!store.taskByThread(bot.id, threadId)) {
|
|
11818
12002
|
return json(res, 409, { error: "the bot switched tasks before it could receive the message" });
|
|
11819
12003
|
}
|
|
@@ -12589,6 +12773,39 @@ const handleRequest = async (req, res) => {
|
|
|
12589
12773
|
}
|
|
12590
12774
|
return json(res, 202, { installing: true });
|
|
12591
12775
|
}
|
|
12776
|
+
// ── the fleet: client workspaces on this server, through the root agent ──
|
|
12777
|
+
// Admin scope by default plus the `admin` entitlement; the socket's own
|
|
12778
|
+
// permissions decide whether this workspace may drive the agent at all.
|
|
12779
|
+
const fleetRoute = /^\/api\/fleet(?:\/(workspaces(?:\/([a-z0-9-]+)(?:\/(users|suspend|resume))?)?|upgrade))?$/.exec(path);
|
|
12780
|
+
if (fleetRoute) {
|
|
12781
|
+
if (!entitled("admin"))
|
|
12782
|
+
return json(res, 403, { error: "Workspaces need an enterprise licence with the admin feature." });
|
|
12783
|
+
const socket = fleetSocketPath();
|
|
12784
|
+
if (!fleetAvailable(socket))
|
|
12785
|
+
return json(res, 404, { error: "No fleet agent on this server. Run `openmausbot fleet init --domain … --operator <this user>` as root." });
|
|
12786
|
+
const [, resource, slug, sub] = fleetRoute;
|
|
12787
|
+
let forward = null;
|
|
12788
|
+
if (method === "GET" && !resource)
|
|
12789
|
+
forward = { method: "GET", path: "/workspaces" };
|
|
12790
|
+
else if (method === "POST" && resource === "workspaces")
|
|
12791
|
+
forward = { method: "POST", path: "/workspaces", body: await readBody(req, 256 * 1024) };
|
|
12792
|
+
else if (method === "POST" && resource === "upgrade")
|
|
12793
|
+
forward = { method: "POST", path: "/upgrade" };
|
|
12794
|
+
else if (slug && method === "POST" && (sub === "users" || sub === "suspend" || sub === "resume"))
|
|
12795
|
+
forward = { method: "POST", path: `/workspaces/${slug}/${sub}`, ...(sub === "users" ? { body: await readBody(req, 8192) } : {}) };
|
|
12796
|
+
else if (slug && method === "DELETE" && !sub)
|
|
12797
|
+
forward = { method: "DELETE", path: `/workspaces/${slug}`, body: await readBody(req, 8192) };
|
|
12798
|
+
if (!forward)
|
|
12799
|
+
return json(res, 405, { error: "no such fleet operation" });
|
|
12800
|
+
res.setHeader("cache-control", "no-store");
|
|
12801
|
+
try {
|
|
12802
|
+
const reply = await fleetRequest(socket, forward.method, forward.path, forward.body);
|
|
12803
|
+
return json(res, reply.status, reply.body ?? {});
|
|
12804
|
+
}
|
|
12805
|
+
catch (error) {
|
|
12806
|
+
return json(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
12807
|
+
}
|
|
12808
|
+
}
|
|
12592
12809
|
// Which edition this server runs and why (see server/enterprise.ts). Read-only.
|
|
12593
12810
|
if (method === "GET" && path === "/api/edition") {
|
|
12594
12811
|
return json(res, 200, editionStatus());
|
|
@@ -12619,6 +12836,62 @@ const handleRequest = async (req, res) => {
|
|
|
12619
12836
|
// Read-only like the inspector above: the rows were written at the
|
|
12620
12837
|
// request.opened fold and in answerRequest; this only reads them back,
|
|
12621
12838
|
// newest last, same order as thread events.
|
|
12839
|
+
// ── the usage ledger: what this workspace spent over a period ──
|
|
12840
|
+
// Read-only over the month files usage-ledger.ts appends at turn.completed.
|
|
12841
|
+
// Admin scope by default, like every route not opened to clients.
|
|
12842
|
+
if (method === "GET" && (path === "/api/usage" || path === "/api/usage.csv")) {
|
|
12843
|
+
const range = parseUsageRange(url.searchParams.get("from"), url.searchParams.get("to"));
|
|
12844
|
+
if (!range)
|
|
12845
|
+
return json(res, 400, { error: "from and to must be YYYY-MM-DD, from no later than to, at most a year apart" });
|
|
12846
|
+
const rows = readUsage(DATA_DIR, range);
|
|
12847
|
+
// The operator's price list is applied only with the billing entitlement.
|
|
12848
|
+
const prices = entitled("billing") && cfg.billing?.prices && Object.keys(cfg.billing.prices).length ? cfg.billing.prices : null;
|
|
12849
|
+
if (path === "/api/usage.csv") {
|
|
12850
|
+
const stamp = (date) => date.toISOString().slice(0, 10);
|
|
12851
|
+
res.writeHead(200, {
|
|
12852
|
+
"content-type": "text/csv; charset=utf-8",
|
|
12853
|
+
"content-disposition": `attachment; filename="usage-${stamp(range.from)}-${stamp(range.to)}.csv"`,
|
|
12854
|
+
"cache-control": "no-store",
|
|
12855
|
+
});
|
|
12856
|
+
res.end(usageCsv(rows, prices));
|
|
12857
|
+
return;
|
|
12858
|
+
}
|
|
12859
|
+
const requested = url.searchParams.get("groupBy") ?? "bot";
|
|
12860
|
+
if (!USAGE_GROUPINGS.includes(requested)) {
|
|
12861
|
+
return json(res, 400, { error: `groupBy must be one of ${USAGE_GROUPINGS.join(", ")}` });
|
|
12862
|
+
}
|
|
12863
|
+
const groupBy = requested;
|
|
12864
|
+
res.setHeader("cache-control", "no-store");
|
|
12865
|
+
return json(res, 200, {
|
|
12866
|
+
from: range.from.toISOString(),
|
|
12867
|
+
to: range.to.toISOString(),
|
|
12868
|
+
groupBy,
|
|
12869
|
+
...summarizeUsage(rows, groupBy, prices),
|
|
12870
|
+
budget: spendState(cfg, DATA_DIR),
|
|
12871
|
+
billing: prices ? { currency: cfg.billing?.currency ?? "USD" } : null,
|
|
12872
|
+
});
|
|
12873
|
+
}
|
|
12874
|
+
// ── provider key check: does a pasted or saved key open the provider's door ──
|
|
12875
|
+
// One read-only request from this server; the verdict never carries the
|
|
12876
|
+
// key. Admin scope by default, like every route not opened to clients.
|
|
12877
|
+
if (method === "POST" && path === "/api/keys/test") {
|
|
12878
|
+
const body = await readBody(req, 8192);
|
|
12879
|
+
const provider = body?.provider;
|
|
12880
|
+
if (!PROVIDER_KEY_KINDS.includes(provider)) {
|
|
12881
|
+
return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
|
|
12882
|
+
}
|
|
12883
|
+
const kind = provider;
|
|
12884
|
+
const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : cfg.xai;
|
|
12885
|
+
const pasted = typeof body?.key === "string" ? body.key.trim() : "";
|
|
12886
|
+
const key = pasted || saved?.key || "";
|
|
12887
|
+
if (!key)
|
|
12888
|
+
return json(res, 400, { error: "No key to test. Paste one or save one first." });
|
|
12889
|
+
if (key.length > 512)
|
|
12890
|
+
return json(res, 400, { error: "That does not look like an API key." });
|
|
12891
|
+
const url = typeof body?.url === "string" && body.url.trim() ? body.url.trim() : saved?.url;
|
|
12892
|
+
res.setHeader("cache-control", "no-store");
|
|
12893
|
+
return json(res, 200, await checkProviderKey({ provider: kind, key, url }));
|
|
12894
|
+
}
|
|
12622
12895
|
if (method === "GET" && path === "/api/decisions") {
|
|
12623
12896
|
const rawLimit = url.searchParams.get("limit");
|
|
12624
12897
|
const parsedLimit = rawLimit === null ? undefined : Number(rawLimit);
|
|
@@ -12941,6 +13214,42 @@ const handleRequest = async (req, res) => {
|
|
|
12941
13214
|
mcpConfigBusy = false;
|
|
12942
13215
|
}
|
|
12943
13216
|
}
|
|
13217
|
+
// Paste-to-add: the {"mcpServers": {...}} block every other agent tool
|
|
13218
|
+
// writes. Same rules as POST: disabled until explicitly enabled.
|
|
13219
|
+
if (method === "POST" && path === "/api/mcp/servers/import") {
|
|
13220
|
+
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
13221
|
+
return json(res, 415, { error: "content-type must be application/json" });
|
|
13222
|
+
}
|
|
13223
|
+
if (mcpConfigBusy)
|
|
13224
|
+
return json(res, 409, { error: "MCP servers are already being updated." });
|
|
13225
|
+
mcpConfigBusy = true;
|
|
13226
|
+
try {
|
|
13227
|
+
const body = await readBody(req);
|
|
13228
|
+
const text = typeof body?.json === "string" ? body.json : "";
|
|
13229
|
+
if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
13230
|
+
return json(res, 401, { error: "unauthorized: this session has expired or was revoked" });
|
|
13231
|
+
}
|
|
13232
|
+
if (!text.trim())
|
|
13233
|
+
return json(res, 400, { error: "Paste the JSON block first." });
|
|
13234
|
+
const parsed = parseMcpServersImport(text);
|
|
13235
|
+
if (!parsed.ok)
|
|
13236
|
+
return json(res, 400, { error: parsed.error });
|
|
13237
|
+
const current = cfg.mcpServers ?? {};
|
|
13238
|
+
const names = Object.keys(parsed.servers);
|
|
13239
|
+
const taken = names.filter((name) => Object.hasOwn(current, name));
|
|
13240
|
+
if (taken.length) {
|
|
13241
|
+
return json(res, 409, { error: `Already added: ${taken.join(", ")}. Remove or rename ${taken.length === 1 ? "it" : "them"} first.` });
|
|
13242
|
+
}
|
|
13243
|
+
if (Object.keys(current).length + names.length > MAX_MCP_SERVERS) {
|
|
13244
|
+
return json(res, 400, { error: `You can add at most ${MAX_MCP_SERVERS} MCP servers.` });
|
|
13245
|
+
}
|
|
13246
|
+
persistMcpServers({ ...current, ...parsed.servers });
|
|
13247
|
+
return json(res, 201, { ...mcpServerResponse(), added: names });
|
|
13248
|
+
}
|
|
13249
|
+
finally {
|
|
13250
|
+
mcpConfigBusy = false;
|
|
13251
|
+
}
|
|
13252
|
+
}
|
|
12944
13253
|
const mcpServerRoute = /^\/api\/mcp\/servers\/([a-z][a-z0-9_-]{0,31})$/.exec(path);
|
|
12945
13254
|
if (mcpServerRoute && ["PUT", "PATCH", "DELETE"].includes(method)) {
|
|
12946
13255
|
if (method !== "DELETE" && !String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
@@ -13730,7 +14039,19 @@ const handleRequest = async (req, res) => {
|
|
|
13730
14039
|
return json(res, 409, { error: "this bot's cloud computer is being changed — wait for it to finish" });
|
|
13731
14040
|
}
|
|
13732
14041
|
if (bot.cloudBackend === "vps") {
|
|
13733
|
-
|
|
14042
|
+
if (m[2] === "screenshot") {
|
|
14043
|
+
let preview = vpsPreviewRequests.get(botId);
|
|
14044
|
+
if (!preview) {
|
|
14045
|
+
preview = vps.vpsComputerScreenshot(cfg, botId).finally(() => {
|
|
14046
|
+
vpsPreviewRequests.delete(botId);
|
|
14047
|
+
});
|
|
14048
|
+
vpsPreviewRequests.set(botId, preview);
|
|
14049
|
+
}
|
|
14050
|
+
return json(res, 200, await preview);
|
|
14051
|
+
}
|
|
14052
|
+
// Opening the existing SSH viewer can coexist with a capture. Start,
|
|
14053
|
+
// stop, remove and Settings deletion still exclude pending previews.
|
|
14054
|
+
const releaseComputerLifecycle = claimBotComputerLifecycle(botId, m[2] === "join");
|
|
13734
14055
|
try {
|
|
13735
14056
|
if (m[2] === "exec") {
|
|
13736
14057
|
return json(res, 409, { error: "the VPS console is available to the bot through its scoped computer tools" });
|
|
@@ -13744,8 +14065,6 @@ const handleRequest = async (req, res) => {
|
|
|
13744
14065
|
if (m[2] === "join") {
|
|
13745
14066
|
return json(res, 200, await vps.vpsComputerJoin(cfg, botId));
|
|
13746
14067
|
}
|
|
13747
|
-
if (m[2] === "screenshot")
|
|
13748
|
-
return json(res, 200, await vps.vpsComputerScreenshot(cfg, botId));
|
|
13749
14068
|
const action = m[2] === "provision" ? "provision" : m[2] === "remove" ? "remove" : "stop";
|
|
13750
14069
|
return json(res, 200, await vps.vpsComputerAction(action, cfg, botId));
|
|
13751
14070
|
}
|
|
@@ -13806,6 +14125,9 @@ const handleRequest = async (req, res) => {
|
|
|
13806
14125
|
const status = e?.status ?? 500;
|
|
13807
14126
|
return json(res, status, { error: e instanceof Error ? e.message : String(e) });
|
|
13808
14127
|
}
|
|
14128
|
+
finally {
|
|
14129
|
+
releaseWorkspaceRequest?.();
|
|
14130
|
+
}
|
|
13809
14131
|
};
|
|
13810
14132
|
const server = createServer(handleRequest);
|
|
13811
14133
|
calendarCalls.start();
|
|
@@ -13887,11 +14209,14 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
13887
14209
|
},
|
|
13888
14210
|
() => flushAllProfileHistory(),
|
|
13889
14211
|
() => flushAllMemoryJournals(),
|
|
14212
|
+
() => flushUsageLedger(DATA_DIR),
|
|
14213
|
+
() => flushDecisionLog(DATA_DIR),
|
|
13890
14214
|
],
|
|
13891
14215
|
// Cleanup jobs run concurrently. Release only after they settle (or reach
|
|
13892
14216
|
// the shutdown deadline), immediately before the process exits, so no new
|
|
13893
14217
|
// server can overlap with a still-mutating old one.
|
|
13894
14218
|
exit: (code) => {
|
|
14219
|
+
closeMessageDb();
|
|
13895
14220
|
releaseDataDirLeaseAtExit();
|
|
13896
14221
|
process.exit(code);
|
|
13897
14222
|
},
|
|
@@ -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,
|