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.
- package/dist/assets/index-BFPYw6P-.js +296 -0
- package/dist/assets/{index-CJtyBonr.js → index-Bpai86ua.js} +1 -1
- package/dist/assets/index-D1odUdTi.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/index.js +7477 -2481
- package/dist-server/local-computer.js +4 -0
- package/dist-server/openmausbot.js +1589 -565
- package/dist-server/pair-cli.js +1589 -565
- 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/claude.js +14 -7
- 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 +316 -8
- 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/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 : "" },
|
|
@@ -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, {
|
|
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
|
|
111
|
-
//
|
|
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
|
+
}
|