openmausbot 0.1.85 → 0.1.86
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/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
- package/dist/assets/arc-DW7mrI4p.js +1 -0
- package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
- package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
- package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
- package/dist/assets/channel-BXFkgus9.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
- package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
- package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
- package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
- package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
- package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
- package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
- package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
- package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
- package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
- package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
- package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
- package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
- package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
- package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
- package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
- package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
- package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
- package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
- package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
- package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
- package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
- package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
- package/dist/assets/graph-D8UmR1vK.js +1 -0
- package/dist/assets/index-BGgqlWPJ.css +1 -0
- package/dist/assets/index-BtHo8ZKl.js +577 -0
- package/dist/assets/{index-CTNNoqSJ.js → index-h9FFNwTu.js} +1 -1
- package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
- package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
- package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
- package/dist/assets/linear-3su2TG93.js +1 -0
- package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
- package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
- package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
- package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
- package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
- package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
- package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
- package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
- package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
- package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
- package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
- package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
- package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
- package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
- package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +19 -10
- package/dist-server/drivers/agents-proxy.js +16159 -1596
- package/dist-server/enterprise/LICENSE +32 -0
- package/dist-server/enterprise/server/index.js +5 -5
- package/dist-server/index.js +4779 -1667
- package/dist-server/mcp-server.js +3 -1
- package/dist-server/openmausbot.js +4679 -4266
- package/dist-server/pair-cli.js +4679 -4266
- package/dist-server/server/admin-activity.js +410 -0
- package/dist-server/server/bot-visibility.js +462 -0
- package/dist-server/server/box.js +2 -1
- package/dist-server/server/browser-runtime.js +1 -1
- package/dist-server/server/channel-queue.js +2 -0
- package/dist-server/server/cli-prompts.js +24 -2
- package/dist-server/server/cli.js +79 -10
- package/dist-server/server/composio.js +49 -11
- package/dist-server/server/config.js +79 -13
- package/dist-server/server/decision-log.js +209 -48
- package/dist-server/server/default-model-selection.js +20 -3
- package/dist-server/server/delegations.js +11 -9
- package/dist-server/server/drivers/acp/core.js +110 -22
- package/dist-server/server/drivers/acp/grok.js +4 -3
- package/dist-server/server/drivers/agents-call.js +1123 -0
- package/dist-server/server/drivers/agents-catalog.js +659 -0
- package/dist-server/server/drivers/agents-client.js +28 -0
- package/dist-server/server/drivers/agents-proxy.js +18 -1722
- package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
- package/dist-server/server/drivers/claude.js +21 -6
- package/dist-server/server/drivers/codex-mcp-names.js +44 -0
- package/dist-server/server/drivers/codex.js +26 -4
- package/dist-server/server/drivers/grok.js +2 -1
- package/dist-server/server/drivers/openai-compat.js +15 -8
- package/dist-server/server/enterprise.js +97 -16
- package/dist-server/server/external-runtime.js +83 -0
- package/dist-server/server/guarded-requests.js +60 -0
- package/dist-server/server/harness/http.js +61 -0
- package/dist-server/server/hosted-models.js +151 -0
- package/dist-server/server/hosted-slack.js +11 -0
- package/dist-server/server/index.js +1718 -348
- package/dist-server/server/managed-desktop.js +79 -14
- package/dist-server/server/managed-policy.js +153 -0
- package/dist-server/server/message-db.js +28 -0
- package/dist-server/server/model-context-window.js +7 -0
- package/dist-server/server/model-prices.js +121 -0
- package/dist-server/server/notify.js +10 -1
- package/dist-server/server/peer-roster.js +13 -2
- package/dist-server/server/recent-work.js +15 -0
- package/dist-server/server/request-auth.js +96 -0
- package/dist-server/server/room-handoffs.js +25 -11
- package/dist-server/server/routes/hosted-slack.js +20 -0
- package/dist-server/server/routes/table.js +14 -0
- package/dist-server/server/routines.js +22 -0
- package/dist-server/server/spend.js +111 -16
- package/dist-server/server/steer-queue.js +6 -1
- package/dist-server/server/store.js +292 -21
- package/dist-server/server/team-backup.js +10 -1
- package/dist-server/server/thread-starters.js +53 -0
- package/dist-server/server/usage-ledger.js +31 -8
- package/dist-server/server/workspace-backup-policy.js +9 -2
- package/dist-server/server/workspace-backup.js +7 -4
- package/dist-server/shared/mention-boundary.js +15 -0
- package/dist-server/shared/routines.js +7 -1
- package/dist-server/shared/team-backup.js +2 -0
- package/dist-server/src/lib/composer-attachments.js +16 -0
- package/dist-server/vps-container-mcp.js +19 -10
- package/enterprise/README.md +33 -12
- package/enterprise/server/index.js +5 -5
- package/package.json +1 -1
- package/dist/assets/index-Ba9G44HI.js +0 -310
- package/dist/assets/index-CkRNp7wX.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// OpenMausBot server — the harness host. Clients hold no transports
|
|
2
2
|
// (upstream rule): the React app dispatches typed commands over HTTP and
|
|
3
3
|
// folds one SSE event stream; every provider process runs here.
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
5
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
6
|
import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs";
|
|
6
7
|
import { writeFileAtomic } from "./atomic.js";
|
|
@@ -8,6 +9,7 @@ import { rm as removeDirectory } from "node:fs/promises";
|
|
|
8
9
|
import { createServer } from "node:http";
|
|
9
10
|
import { ToolResults, TOOL_RESULT_MAX_CHARS } from "./tool-results.js";
|
|
10
11
|
import { extname, join } from "node:path";
|
|
12
|
+
import { authorizeExternalRuntime, externalRuntimeIsActive } from "./external-runtime.js";
|
|
11
13
|
import { z } from "zod";
|
|
12
14
|
import { selectReplay, DEFAULT_REBUILD_BYTES, MAX_SUMMARY_BYTES } from "./context-rebuild.js";
|
|
13
15
|
import { draftSummary, foldPoint } from "./compaction-summary.js";
|
|
@@ -16,6 +18,7 @@ import { autoCompactWindow } from "./drivers/claude.js";
|
|
|
16
18
|
import { SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
|
|
17
19
|
import { SharedComputerControl } from "./shared-computer-control.js";
|
|
18
20
|
import { RoomHandoffs } from "./room-handoffs.js";
|
|
21
|
+
import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsInput, requestSourceForCard } from "./guarded-requests.js";
|
|
19
22
|
import { botAvatarUrlFromStoredPath } from "../shared/bot-avatar.js";
|
|
20
23
|
import { BOT_PROFILE_LIMITS } from "../shared/bot-profile.js";
|
|
21
24
|
import { CLOUD_COMPUTER_BUSY_ERROR } from "../shared/computer-contention.js";
|
|
@@ -31,7 +34,7 @@ import { BrowserCleanupCoordinator, finalizeBrowserCleanupMutation, requireBrows
|
|
|
31
34
|
import * as checkpoints from "./checkpoints.js";
|
|
32
35
|
import { commandReceipt } from "./commands.js";
|
|
33
36
|
import { buildTurnDigest, coverageForDriver, digestPromptLine, renderDigest, toolEvidence } from "./digest.js";
|
|
34
|
-
import { appendDecision,
|
|
37
|
+
import { appendDecision, bindDecisionRetention, boundRetentionDays, decisionRetentionDays, decisionsCsv, flushDecisionLog, pruneDecisions, readDecisionRange, readDecisions, withDecisionActor } from "./decision-log.js";
|
|
35
38
|
import { validateBotCwd } from "./bot-cwd.js";
|
|
36
39
|
import { ATTACHMENTS_DIR, attachmentExists, cleanupStaleAttachmentPartials, deleteAttachment, extensionForMime, FILE_MAX_BYTES, IMAGE_MAX_BYTES, readAttachment, saveFile, saveImage, saveImageUpload, validateAttachmentUploadId, } from "./attachments.js";
|
|
37
40
|
import { messageFileDisposition, messageFileDownloadName, messageAttachmentName, messageFileRoots, messageImageTargetAt, messageReferencesFile, openMessageFile, } from "./message-file.js";
|
|
@@ -50,19 +53,20 @@ import { chiefOfStaffSystemPrompt } from "./chief-of-staff.js";
|
|
|
50
53
|
import { canAccessTeam, canReachPeer, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
|
|
51
54
|
import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
|
|
52
55
|
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerRuntimeStatus, localVmRecreatableOnDemand, perBotLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, } from "./container-computer.js";
|
|
53
|
-
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, } from "./config.js";
|
|
56
|
+
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
|
|
54
57
|
import { sweepThreadEventLogs } from "./thread-retention.js";
|
|
55
58
|
import { ComputerControl } from "./computer-control.js";
|
|
56
59
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
57
60
|
import { registerEnginesBinDir } from "./engine-install.js";
|
|
58
61
|
import { appendUsage, parseUsageRange, readUsage, summarizeUsage, usageCsv, USAGE_GROUPINGS, flushUsageLedger } from "./usage-ledger.js";
|
|
62
|
+
import { ledgerCost } from "./model-prices.js";
|
|
59
63
|
import { checkProviderKey, PROVIDER_KEY_KINDS } from "./provider-key-check.js";
|
|
60
|
-
import { assertWithinBudget, noteSpend, spendState } from "./spend.js";
|
|
64
|
+
import { assertWithinBudget, noteSpend, spendAlertText, spendState, takeSpendAlert } from "./spend.js";
|
|
61
65
|
import { fleetAvailable, fleetRequest, fleetSocketPath } from "./fleet-client.js";
|
|
62
66
|
import { entitled } from "./enterprise.js";
|
|
63
67
|
import { HOSTED_CONTRACT_HEADER, HOSTED_CONTRACT_METADATA, HOSTED_CONTRACT_VERSION } from "./hosted-contract.js";
|
|
64
68
|
import { describeSpawnFailure, execCli } from "./procs.js";
|
|
65
|
-
import { blockedTarget, buildNotification } from "./notify.js";
|
|
69
|
+
import { blockedTarget, buildNotification, buildSpendNotification } from "./notify.js";
|
|
66
70
|
import { isModelVariant, newId, } from "./contracts.js";
|
|
67
71
|
import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
|
|
68
72
|
import { decodeGeneratedImage } from "./generated-image.js";
|
|
@@ -71,9 +75,9 @@ import { probeMcpServer } from "./mcp-probe.js";
|
|
|
71
75
|
import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
|
|
72
76
|
import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
|
|
73
77
|
import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
|
|
74
|
-
import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups } from "./message-db.js";
|
|
78
|
+
import { readMessageText, recallMessages, recentMessages, searchMessages, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
|
|
75
79
|
import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
|
|
76
|
-
import { parseSince, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
|
|
80
|
+
import { parseSince, parseUntil, recentWork, recentWorkPrompt, turnOutcomeLine } from "./recent-work.js";
|
|
77
81
|
import { chiefForBot, INCIDENTS_THREAD_TITLE, IncidentLedger, incidentChip, incidentText } from "./incidents.js";
|
|
78
82
|
/** A session_read answer competes with the transcript for the context
|
|
79
83
|
* window; a computer-use turn's output can run to hundreds of KB. */
|
|
@@ -86,6 +90,8 @@ import { acceptedSendMatch, parseSendId, sendFingerprint, SendSequencer, } from
|
|
|
86
90
|
import { EventBus } from "./harness/bus.js";
|
|
87
91
|
import { ProviderRegistry } from "./harness/registry.js";
|
|
88
92
|
import { ManagedDesktopProviders } from "./managed-desktop.js";
|
|
93
|
+
import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
|
|
94
|
+
import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
|
|
89
95
|
import { selectDefaultModelSelection } from "./default-model-selection.js";
|
|
90
96
|
import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
|
|
91
97
|
import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
|
|
@@ -156,7 +162,7 @@ import { parseSurface, resolveSurface, surfaceLabel, surfaceOfComputerKind, surf
|
|
|
156
162
|
import { PendingTurnCancellations, ProviderTurnGenerationRegistry, RetiredTurnRegistry, guardTurnDispatch, isTurnAdmissionBlocked, isTurnEventQuarantined, } from "./turn-dispatch-guard.js";
|
|
157
163
|
import { createGracefulShutdown } from "./graceful-shutdown.js";
|
|
158
164
|
import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
|
|
159
|
-
import { createWorkspaceAccess, describeEdition, editionStatus, hostedWorkspaceConfiguration, hostedWorkspaceConfigured, loadEnterpriseLayer } from "./enterprise.js";
|
|
165
|
+
import { createWorkspaceAccess, describeEdition, editionStatus, hostedWorkspaceConfiguration, hostedWorkspaceConfigured, editionForMembers, LICENSE_WARN_DAYS, licenseWarning, sharedWorkspaceFullAccessConfigured, loadEnterpriseLayer, workspaceMembership } from "./enterprise.js";
|
|
160
166
|
import { environmentDescriptor, loadEnvironmentId, serverVersion } from "./environment.js";
|
|
161
167
|
import { WorkspaceBackupMaintenance } from "./workspace-backup-maintenance.js";
|
|
162
168
|
import { createWorkspaceBackupRoutes, isWorkspaceBackupSessionControl } from "./workspace-backup-http.js";
|
|
@@ -164,11 +170,19 @@ import { applyPendingWorkspaceRestore, readLastWorkspaceRestore } from "./worksp
|
|
|
164
170
|
import { createCustomDomainVerifier, customDomainIpv4, normalizeCustomDomain } from "./custom-domain.js";
|
|
165
171
|
import { allowedScopes, createEmailSignIn, parseAllowList } from "./account-signin.js";
|
|
166
172
|
import { ProviderAuthSessions } from "./provider-auth-sessions.js";
|
|
167
|
-
import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, isLoopbackHost, isProxied, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
|
|
173
|
+
import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, isLoopbackHost, isProxied, labelFromUserAgent, requestOrigin, requestSource, requiredScope, resolveLoopbackTrust, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
|
|
168
174
|
import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
|
|
175
|
+
import { ThreadStarters } from "./thread-starters.js";
|
|
176
|
+
import { activityCsv, activityEntries, appendAdminAction, botAuditSnapshot, botChangeRows, configChangeRows, parseActivityWhat, flushAdminActivity, pruneAdminActivity, readAdminActivityRange, sharedSignIn, signInListsOf, } from "./admin-activity.js";
|
|
177
|
+
import { frameForMember, memberBody, memberBot, noteSeen, notFoundFor, audienceEquals, memberGroup, narrowestAudience, parseVisibility, pathSubject, roomFeeds, routineVisible, sameAudience, SEES_EVERYTHING, VisibleSet, } from "./bot-visibility.js";
|
|
169
178
|
import { describeBrand, loadBrand } from "./brand.js";
|
|
170
179
|
import { deliverSseFrame } from "./sse-fanout.js";
|
|
171
180
|
import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, PhoneSecretSubmissionRegistry, assertPhoneSecretRequestMatches, phoneSecretOperationId, } from "./phone-secret.js";
|
|
181
|
+
// Keep these two last: a route module may import any server module, and
|
|
182
|
+
// loading the table after everything above leaves module start-up order as is.
|
|
183
|
+
import { json, onJsonBody, parsedBodyOf, readBody } from "./harness/http.js";
|
|
184
|
+
import { ROUTES, dispatchRoutes } from "./routes/table.js";
|
|
185
|
+
import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
|
|
172
186
|
const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
|
|
173
187
|
const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
|
|
174
188
|
// Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
|
|
@@ -232,10 +246,37 @@ const sessions = new SessionRegistry({
|
|
|
232
246
|
portalMembership: hostedWorkspaceConfiguration()?.portalMembership === true,
|
|
233
247
|
});
|
|
234
248
|
const sharedComputers = new SharedComputers(id => sessions.isLive(id));
|
|
249
|
+
// Who each thread is for, when a signed-in person can be named (server-private).
|
|
250
|
+
const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
|
|
235
251
|
const SESSION_COOKIE = sessionCookieName(PORT, ENVIRONMENT_ID);
|
|
236
252
|
const HOSTED_WORKSPACE = hostedWorkspaceConfigured();
|
|
237
253
|
let workspaceAccess = null;
|
|
238
254
|
const DESKTOP_MANAGED = process.env.OMB_DESKTOP_PARENT === "1";
|
|
255
|
+
const SHARED_WORKSPACE_FULL_ACCESS = sharedWorkspaceFullAccessConfigured();
|
|
256
|
+
const sharedWorkspaceFullAccessEnabled = () => SHARED_WORKSPACE_FULL_ACCESS && Boolean(workspaceAccess) && entitled("admin");
|
|
257
|
+
// Who a loopback request without a session is (server/request-auth.ts
|
|
258
|
+
// LoopbackTrust): the owner on a desktop or a one-person server; a service on
|
|
259
|
+
// a shared workspace, where every bot's shell is a loopback caller too.
|
|
260
|
+
const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE });
|
|
261
|
+
// `openmausbot serve` on a service-trust server hands the server it starts a
|
|
262
|
+
// per-launch secret on stdin, then closes it (server/cli.ts). It opens only
|
|
263
|
+
// the pairing route, for that CLI. Never an environment variable: every
|
|
264
|
+
// engine this server starts inherits its environment.
|
|
265
|
+
let cliOwnerToken;
|
|
266
|
+
if (process.env.OMB_CLI_OWNER_STDIN === "1" && LOOPBACK.trust === "service" && process.stdin) {
|
|
267
|
+
let received = "";
|
|
268
|
+
process.stdin.setEncoding("utf8");
|
|
269
|
+
process.stdin.on("error", () => { });
|
|
270
|
+
process.stdin.on("data", (chunk) => {
|
|
271
|
+
if (cliOwnerToken !== undefined || received.length > 256)
|
|
272
|
+
return;
|
|
273
|
+
received += chunk;
|
|
274
|
+
const line = received.split("\n", 1)[0];
|
|
275
|
+
if (received.includes("\n") && /^[A-Za-z0-9_-]{43}$/.test(line))
|
|
276
|
+
cliOwnerToken = line;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
delete process.env.OMB_CLI_OWNER_STDIN;
|
|
239
280
|
// Empty is deliberately a deny-all bootstrap state. Only Electron's private
|
|
240
281
|
// utility-process port can replace it with the per-launch owner capability.
|
|
241
282
|
let desktopMutationToken = DESKTOP_MANAGED ? "" : undefined;
|
|
@@ -243,10 +284,22 @@ let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
|
|
|
243
284
|
// Where remote clients reach this server (a proxy's public address); pairing URLs use it.
|
|
244
285
|
const FALLBACK_PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
|
|
245
286
|
const cfg = loadConfig();
|
|
287
|
+
const hostedModels = hostedModelPolicy(DATA_DIR);
|
|
288
|
+
const providerConfigs = () => hostedModels ? hostedModels.configs() : instanceConfigs(cfg);
|
|
289
|
+
const decorateHostedProvider = hostedModels ? (instance) => hostedModels.decorate(instance) : undefined;
|
|
290
|
+
if (hostedModels) {
|
|
291
|
+
const selection = hostedModels.select(cfg.defaultModelSelection);
|
|
292
|
+
if (selection.instanceId && JSON.stringify(selection) !== JSON.stringify(cfg.defaultModelSelection))
|
|
293
|
+
saveConfig({ defaultModelSelection: selection });
|
|
294
|
+
cfg.defaultModelSelection = selection;
|
|
295
|
+
}
|
|
246
296
|
// The per-thread event log cap is checked after every NDJSON append.
|
|
247
297
|
// config.json is read once per process (a change restarts the server, like
|
|
248
298
|
// every other hand-edited knob), so a binding made here never goes stale.
|
|
249
299
|
bindThreadLogCapProvider(() => threadEventLogMaxBytes(cfg));
|
|
300
|
+
// The decision log keeps month files for at least this many days. Read per
|
|
301
|
+
// prune, so a Settings change applies at the next one without a restart.
|
|
302
|
+
bindDecisionRetention(() => decisionRetentionDays(cfg.decisions?.retentionDays));
|
|
250
303
|
const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
|
|
251
304
|
// "Sign in with your email" on /pair: the allow-list is read per call so a
|
|
252
305
|
// Settings change or an env bootstrap applies without a restart.
|
|
@@ -276,11 +329,15 @@ const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
|
|
|
276
329
|
// Engines installed from Settings live under the data directory and win over
|
|
277
330
|
// any other copy on PATH.
|
|
278
331
|
registerEnginesBinDir();
|
|
279
|
-
// Who asked for the
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
332
|
+
// Who asked for the turn running (or last run) on each thread, read by the
|
|
333
|
+
// usage ledger when it settles. It is set when a turn is ADMITTED, from the
|
|
334
|
+
// message that started it — never when a message merely queues behind a
|
|
335
|
+
// running turn or is steered into one, so a second person's words cannot
|
|
336
|
+
// re-book the first person's turn. A queued message carries its sender's
|
|
337
|
+
// trigger until it drains and starts its own turn. Continuations (a resumed
|
|
338
|
+
// connector, a delegation wake) set nothing and stay with the person whose
|
|
339
|
+
// ask they continue; routine and peer turns are told apart before this is
|
|
340
|
+
// consulted.
|
|
284
341
|
const turnTriggers = new Map();
|
|
285
342
|
/** Who a user message is from, when that is someone other than the desktop
|
|
286
343
|
* owner. Loopback is the owner by design, so it stays unstamped and reads as
|
|
@@ -291,15 +348,509 @@ function messageSender(auth) {
|
|
|
291
348
|
if (auth.kind !== "session")
|
|
292
349
|
return undefined;
|
|
293
350
|
const name = (auth.session.email ?? auth.session.label ?? "").trim();
|
|
294
|
-
return name ? { name } : undefined;
|
|
351
|
+
return name ? { name, id: personKey(auth.session) } : undefined;
|
|
352
|
+
}
|
|
353
|
+
/** An opaque, stable key for the person behind a session: their account
|
|
354
|
+
* email when they signed in with one (a new device is still them), else the
|
|
355
|
+
* paired session itself. Hashed, so a message or a thread can carry it
|
|
356
|
+
* without handing other members a session id. */
|
|
357
|
+
function personKey(session) {
|
|
358
|
+
const basis = session.email ? `email:${session.email.trim().toLowerCase()}` : `session:${session.id}`;
|
|
359
|
+
return `p_${createHash("sha256").update(basis).digest("base64url").slice(0, 22)}`;
|
|
360
|
+
}
|
|
361
|
+
/** More than one person uses this workspace: portal membership, or an email
|
|
362
|
+
* sign-in list that names members. Only then does who-may-answer narrow. */
|
|
363
|
+
function sharedMembership() {
|
|
364
|
+
return hostedWorkspaceConfiguration()?.portalMembership === true || signInAllowList().members.length > 0;
|
|
365
|
+
}
|
|
366
|
+
/** The person a user line came from, when a session sent it. A bot's line
|
|
367
|
+
* (peerAsk), the owner, a service, a routine or a webhook names nobody. */
|
|
368
|
+
function linePersonKey(message) {
|
|
369
|
+
return message?.role === "user" && !message.peerAsk ? message.sender?.id : undefined;
|
|
370
|
+
}
|
|
371
|
+
/** The opaque key of whoever sent the request a card belongs to: the
|
|
372
|
+
* message the harness proved started the turn, else the last user line
|
|
373
|
+
* before the card. */
|
|
374
|
+
function cardRequesterKey(threadId, requestId) {
|
|
375
|
+
const thread = store.messagesFor(threadId);
|
|
376
|
+
const index = thread.findIndex((message) => message.card?.requestId === requestId);
|
|
377
|
+
if (index < 0)
|
|
378
|
+
return undefined;
|
|
379
|
+
const card = thread[index];
|
|
380
|
+
return linePersonKey(card.requestMessageId
|
|
381
|
+
? thread.find((message) => message.id === card.requestMessageId)
|
|
382
|
+
: thread.slice(0, index).findLast((message) => message.role === "user"));
|
|
383
|
+
}
|
|
384
|
+
/** The signed-in person a thread's current work is for: whoever sent its
|
|
385
|
+
* current (else latest) request from a session, else whoever the thread was
|
|
386
|
+
* opened for. A bot opening a thread while working records this for the new
|
|
387
|
+
* thread, so delegated work leads back to the person who asked. */
|
|
388
|
+
function threadPersonKey(threadId) {
|
|
389
|
+
const thread = store.messagesFor(threadId);
|
|
390
|
+
const owner = directRequestOwners.get(threadId);
|
|
391
|
+
const request = owner?.messageId
|
|
392
|
+
? thread.find((message) => message.id === owner.messageId)
|
|
393
|
+
: thread.findLast((message) => message.role === "user");
|
|
394
|
+
return linePersonKey(request) ?? threadStarters.get(threadId);
|
|
395
|
+
}
|
|
396
|
+
/** Whose session may answer a card. The provider CLI's own approval modes and
|
|
397
|
+
* the harness's proposals stay exactly as they are; this adds no card, gate
|
|
398
|
+
* or prompt, it only decides whose answer to an existing card counts.
|
|
399
|
+
* - the owner on this machine, and admins: any card;
|
|
400
|
+
* - a session-less caller on a shared server (service trust): decline only;
|
|
401
|
+
* - where several people share the workspace and the card can be traced to
|
|
402
|
+
* a person (who sent its request, who the thread was opened for, followed
|
|
403
|
+
* back through threads bots opened for them): those people;
|
|
404
|
+
* - otherwise (one person; or a routine, webhook, Slack, owner-sent or older
|
|
405
|
+
* thread that names nobody): anyone who may chat, as before. */
|
|
406
|
+
function cardAnswerRefusal(auth, threadId, requestId, behavior) {
|
|
407
|
+
if (auth.kind === "loopback") {
|
|
408
|
+
return auth.trust === "service" && behavior !== "deny"
|
|
409
|
+
? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
|
|
410
|
+
: null;
|
|
411
|
+
}
|
|
412
|
+
if (auth.scopes.includes("admin") || !sharedMembership())
|
|
413
|
+
return null;
|
|
414
|
+
const known = [threadStarters.get(threadId), cardRequesterKey(threadId, requestId)].filter((person) => Boolean(person));
|
|
415
|
+
if (!known.length || known.includes(personKey(auth.session)))
|
|
416
|
+
return null;
|
|
417
|
+
return "Only the person who started this conversation or sent this request, or a workspace admin, can answer this card.";
|
|
418
|
+
}
|
|
419
|
+
function decisionActorFor(auth) {
|
|
420
|
+
if (auth.kind === "loopback")
|
|
421
|
+
return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
|
|
422
|
+
const session = auth.session;
|
|
423
|
+
return {
|
|
424
|
+
kind: "session", sessionId: session.id, label: session.label,
|
|
425
|
+
...(session.email ? { email: session.email } : {}),
|
|
426
|
+
// A portal session's id is its membership grant: never written down.
|
|
427
|
+
...(session.userId && !session.userId.startsWith("portal:") ? { userId: session.userId } : {}),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function cardAnswererFor(auth) {
|
|
431
|
+
if (auth.kind === "loopback")
|
|
432
|
+
return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
|
|
433
|
+
return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user" };
|
|
434
|
+
}
|
|
435
|
+
/** Answer a card as `auth`: the decision rows written meanwhile name the
|
|
436
|
+
* answerer, and a card this answer settled records who settled it. A card
|
|
437
|
+
* that was already settled keeps whatever it said. */
|
|
438
|
+
async function answeringCardAs(auth, threadId, requestId, work) {
|
|
439
|
+
const open = (() => {
|
|
440
|
+
const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
|
|
441
|
+
return Boolean(card && !card.answered && !card.dismissed);
|
|
442
|
+
})();
|
|
443
|
+
try {
|
|
444
|
+
await withDecisionActor(decisionActorFor(auth), work);
|
|
445
|
+
}
|
|
446
|
+
finally {
|
|
447
|
+
const message = open ? store.messagesFor(threadId).find((candidate) => candidate.card?.requestId === requestId) : undefined;
|
|
448
|
+
const card = message?.card;
|
|
449
|
+
if (message && card && !card.answeredBy && card.answered !== "unavailable" && (card.answered || card.dismissed)) {
|
|
450
|
+
store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: cardAnswererFor(auth) } });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// ── bot visibility (server/bot-visibility.ts) ──────────────────────────
|
|
455
|
+
/** Who is looking. The owner, a session-less local service (the Slack
|
|
456
|
+
* worker) and admin sessions see every bot; any other session is a member,
|
|
457
|
+
* known by the email it signed in with. */
|
|
458
|
+
function viewerFor(auth) {
|
|
459
|
+
if (auth.kind === "loopback" || auth.scopes.includes("admin"))
|
|
460
|
+
return SEES_EVERYTHING;
|
|
461
|
+
return { kind: "member", ...(auth.session.email ? { email: auth.session.email } : {}) };
|
|
462
|
+
}
|
|
463
|
+
/** Thread → the bot or room that owns it, and one VisibleSet per viewer,
|
|
464
|
+
* both kept until the next bot, room or thread change (store.onChange
|
|
465
|
+
* below), so a stream of live frames costs a map lookup each. */
|
|
466
|
+
const threadOwners = new Map();
|
|
467
|
+
const visibleSets = new Map();
|
|
468
|
+
function threadOwner(threadId) {
|
|
469
|
+
let owner = threadOwners.get(threadId);
|
|
470
|
+
if (owner === undefined) {
|
|
471
|
+
const bot = store.botByThread(threadId);
|
|
472
|
+
const group = bot ? undefined : store.groupByThread(threadId);
|
|
473
|
+
owner = bot ? { bot: bot.id } : group ? { group: group.id } : null;
|
|
474
|
+
if (threadOwners.size >= 20_000)
|
|
475
|
+
threadOwners.clear();
|
|
476
|
+
threadOwners.set(threadId, owner);
|
|
477
|
+
}
|
|
478
|
+
return owner ?? undefined;
|
|
479
|
+
}
|
|
480
|
+
function visibleTo(viewer) {
|
|
481
|
+
const key = viewer.kind === "all" ? "*" : `member:${viewer.email?.trim().toLowerCase() ?? ""}`;
|
|
482
|
+
let set = visibleSets.get(key);
|
|
483
|
+
if (!set) {
|
|
484
|
+
set = new VisibleSet(store.bots, store.groups, viewer, threadOwner);
|
|
485
|
+
if (visibleSets.size >= 1_000)
|
|
486
|
+
visibleSets.clear();
|
|
487
|
+
visibleSets.set(key, set);
|
|
488
|
+
}
|
|
489
|
+
return set;
|
|
490
|
+
}
|
|
491
|
+
function forgetVisibility() {
|
|
492
|
+
threadOwners.clear();
|
|
493
|
+
visibleSets.clear();
|
|
494
|
+
}
|
|
495
|
+
/** Which threads mention an attachment, for a member's file request while a
|
|
496
|
+
* bot is restricted: a scan of the message store, so kept a minute. */
|
|
497
|
+
const attachmentThreads = new Map();
|
|
498
|
+
function threadsUsingAttachment(name) {
|
|
499
|
+
const cached = attachmentThreads.get(name);
|
|
500
|
+
if (cached && Date.now() - cached.at < 60_000)
|
|
501
|
+
return cached.threads;
|
|
502
|
+
const threads = threadsReferencing(name);
|
|
503
|
+
if (attachmentThreads.size >= 2_000)
|
|
504
|
+
attachmentThreads.clear();
|
|
505
|
+
attachmentThreads.set(name, { at: Date.now(), threads });
|
|
506
|
+
return threads;
|
|
507
|
+
}
|
|
508
|
+
/** The one check every request naming a bot, thread, room, routine, run or
|
|
509
|
+
* attachment passes through for a member. An id nobody knows goes on to its
|
|
510
|
+
* handler, which answers "not found" itself. */
|
|
511
|
+
function subjectVisible(subject, visible) {
|
|
512
|
+
switch (subject.kind) {
|
|
513
|
+
case "bot":
|
|
514
|
+
return !store.bot(subject.id) || visible.bot(subject.id);
|
|
515
|
+
case "thread":
|
|
516
|
+
return !threadOwner(subject.id) || visible.thread(subject.id);
|
|
517
|
+
case "group":
|
|
518
|
+
return !store.group(subject.id) || visible.group(subject.id);
|
|
519
|
+
case "routine": {
|
|
520
|
+
const routine = routines?.listRoutines().find((candidate) => candidate.id === subject.id);
|
|
521
|
+
return !routine || routineVisible(routine, visible);
|
|
522
|
+
}
|
|
523
|
+
case "routine-run": {
|
|
524
|
+
const run = routines?.listRuns().find((candidate) => candidate.id === subject.id);
|
|
525
|
+
return !run || routineVisible(run, visible);
|
|
526
|
+
}
|
|
527
|
+
case "attachment":
|
|
528
|
+
return visible.attachment(subject.name, threadsUsingAttachment);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
/** A member may schedule a routine only for a bot or room they can see. */
|
|
532
|
+
function hiddenRoutineTarget(body, visible) {
|
|
533
|
+
if (visible.everything || !body || typeof body !== "object")
|
|
534
|
+
return null;
|
|
535
|
+
const { botId, groupId } = body;
|
|
536
|
+
if (typeof botId === "string" && store.bot(botId) && !visible.bot(botId))
|
|
537
|
+
return "no such bot";
|
|
538
|
+
if (typeof groupId === "string" && store.group(groupId) && !visible.group(groupId))
|
|
539
|
+
return "no such channel";
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
/** Whether a room may feed this bot's recall and recent-work brief: only
|
|
543
|
+
* when everyone who can see the bot can see every bot in the room. */
|
|
544
|
+
function roomFeedsBot(group, bot) {
|
|
545
|
+
return roomFeeds([...roomMemberAudiences(group), group.audienceFloor], bot.visibility);
|
|
546
|
+
}
|
|
547
|
+
function roomMemberAudiences(group) {
|
|
548
|
+
return group.memberIds.flatMap((id) => {
|
|
549
|
+
const member = store.bot(id);
|
|
550
|
+
return member ? [member.visibility] : [];
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
/** Keep a room's floor at the narrowest audience it has ever had. Called
|
|
554
|
+
* whenever a member bot or the room changes (and for every room at start),
|
|
555
|
+
* so the floor is set before a restricted bot can leave: its departure, or
|
|
556
|
+
* its deletion, then never opens the transcript to more people. Only an
|
|
557
|
+
* admin's explicit reset (PATCH /api/groups/:id {resetAudience: true})
|
|
558
|
+
* widens it again, to what its current bots allow. */
|
|
559
|
+
function settleRoomFloor(group) {
|
|
560
|
+
if (group.dm)
|
|
561
|
+
return;
|
|
562
|
+
const next = narrowestAudience([group.audienceFloor, ...roomMemberAudiences(group)]);
|
|
563
|
+
if (audienceEquals(next, group.audienceFloor))
|
|
564
|
+
return;
|
|
565
|
+
store.patchGroup(group.id, { audienceFloor: next === "everyone" ? undefined : next });
|
|
566
|
+
}
|
|
567
|
+
/** The conversations a bot's recent-work brief may draw on: its own, and the
|
|
568
|
+
* rooms roomFeedsBot allows. */
|
|
569
|
+
function recentWorkSources(bot) {
|
|
570
|
+
return {
|
|
571
|
+
groups: store.groups.filter((group) => !group.memberIds.includes(bot.id) || roomFeedsBot(group, bot)),
|
|
572
|
+
taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
/** Bots in one room must be visible to the same people: a room is one
|
|
576
|
+
* transcript, so otherwise a restricted bot's words would feed a teammate
|
|
577
|
+
* everyone can see. Refuse a roster that adds such a bot (existing members
|
|
578
|
+
* may stay while an admin sorts it out), naming the pair in plain words. */
|
|
579
|
+
function roomAudienceConflict(memberIds, existing = []) {
|
|
580
|
+
const members = memberIds.flatMap((id) => {
|
|
581
|
+
const bot = store.bot(id);
|
|
582
|
+
return bot ? [bot] : [];
|
|
583
|
+
});
|
|
584
|
+
for (const added of members.filter((bot) => !existing.includes(bot.id))) {
|
|
585
|
+
const other = members.find((bot) => bot.id !== added.id && !sameAudience(bot.visibility, added.visibility));
|
|
586
|
+
if (other) {
|
|
587
|
+
return `Bots in one room must be visible to the same people. ${added.name} and ${other.name} are not: give them the same visibility first, or use separate rooms.`;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
295
591
|
}
|
|
296
|
-
|
|
297
|
-
|
|
592
|
+
/** A scheduled call with several bots opens a room, so its bots keep one audience too. */
|
|
593
|
+
function calendarAudienceConflict(body) {
|
|
594
|
+
const botIds = body && typeof body === "object" ? body.botIds : undefined;
|
|
595
|
+
if (!Array.isArray(botIds) || botIds.length < 2 || botIds.some((id) => typeof id !== "string"))
|
|
596
|
+
return null;
|
|
597
|
+
return roomAudienceConflict(botIds);
|
|
598
|
+
}
|
|
599
|
+
/** What a member's live stream needs to narrow a frame. */
|
|
600
|
+
function memberFrameContext(visible) {
|
|
601
|
+
return {
|
|
602
|
+
visible,
|
|
603
|
+
webhookBot: (webhookId) => webhooks.list().find((webhook) => webhook.id === webhookId)?.botId,
|
|
604
|
+
freshBot: (botId) => {
|
|
605
|
+
const bot = store.bot(botId);
|
|
606
|
+
return bot ? { ...wireBot(bot), tasks: store.tasks(bot.id).map(wireTask), ...messagePage(bot.threadId, DEFAULT_PAGE) } : undefined;
|
|
607
|
+
},
|
|
608
|
+
freshGroup: (groupId) => {
|
|
609
|
+
const group = store.group(groupId);
|
|
610
|
+
return group ? { ...publicGroupState(group), ...messagePage(group.threadId, DEFAULT_PAGE) } : undefined;
|
|
611
|
+
},
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
// ── admin activity (server/admin-activity.ts) ──────────────────────────
|
|
615
|
+
/** Recorded only where several people share the workspace: a hosted
|
|
616
|
+
* workspace, an email sign-in list naming more than one person (or a whole
|
|
617
|
+
* domain), or a device paired with chat-only access. The desktop app and a
|
|
618
|
+
* one-person server keep no admin activity log, exactly as before. */
|
|
619
|
+
function adminActivityRecording() {
|
|
620
|
+
if (DESKTOP_MANAGED)
|
|
621
|
+
return false;
|
|
622
|
+
if (HOSTED_WORKSPACE)
|
|
623
|
+
return true;
|
|
624
|
+
const chatOnly = (scopes) => !scopes.includes("admin");
|
|
625
|
+
return sharedSignIn(signInAllowList()) || sessions.list().some((session) => chatOnly(session.scopes)) ||
|
|
626
|
+
sessions.openPairings().some((pairing) => chatOnly(pairing.scopes));
|
|
627
|
+
}
|
|
628
|
+
/** Who made an admin change: as the decision log names who answered, plus
|
|
629
|
+
* the command line (it marks its requests; on loopback that is the owner). */
|
|
630
|
+
function adminActorFor(auth, req) {
|
|
631
|
+
if (auth.kind === "loopback" && auth.trust !== "service" &&
|
|
632
|
+
(req.headers["x-openmausbot-cli"] === "1" || req.headers["x-openmausbot-cli-owner"] !== undefined))
|
|
633
|
+
return { kind: "cli" };
|
|
634
|
+
return decisionActorFor(auth);
|
|
635
|
+
}
|
|
636
|
+
/** The admin whose request is running, for config saves made while it runs.
|
|
637
|
+
* Each save records exactly what it wrote, so a slow request never takes
|
|
638
|
+
* the blame (or the credit) for another admin's change made meanwhile. */
|
|
639
|
+
const adminActorScope = new AsyncLocalStorage();
|
|
640
|
+
onConfigSaved((before, after) => {
|
|
641
|
+
const scope = adminActorScope.getStore();
|
|
642
|
+
if (!scope || DESKTOP_MANAGED)
|
|
643
|
+
return;
|
|
644
|
+
// Shared when the request began, or now: the change that makes a server
|
|
645
|
+
// shared, or stops it being, is kept.
|
|
646
|
+
if (!scope.sharedAtStart && !adminActivityRecording() && !sharedSignIn(signInListsOf(before)) && !sharedSignIn(signInListsOf(after)))
|
|
647
|
+
return;
|
|
648
|
+
for (const row of configChangeRows(before, after))
|
|
649
|
+
appendAdminAction(DATA_DIR, { ...row, actor: scope.actor });
|
|
650
|
+
});
|
|
651
|
+
function adminAuditPlan(method, path) {
|
|
652
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
653
|
+
return null;
|
|
654
|
+
if (path.startsWith("/api/internal/") || path.startsWith("/api/testing/"))
|
|
655
|
+
return null;
|
|
656
|
+
const plan = {
|
|
657
|
+
config: requiredScope(method, path, { sharedComputers: sharedComputersEnabled(cfg) }) === "admin",
|
|
658
|
+
createsBots: method === "POST" && (path === "/api/bots" || path === "/api/teams/import"),
|
|
659
|
+
createsWebhook: method === "POST" && path === "/api/webhooks",
|
|
660
|
+
pairing: method === "POST" && path === "/api/auth/pairing",
|
|
661
|
+
};
|
|
662
|
+
let m = /^\/api\/bots\/([\w-]+)(?:\/(always-allow|model))?$/.exec(path);
|
|
663
|
+
if (m && ((!m[2] && method === "PATCH") || (m[2] === "model" && method === "PATCH") || (m[2] === "always-allow" && method === "POST")))
|
|
664
|
+
plan.botId = m[1];
|
|
665
|
+
if (m && !m[2] && method === "DELETE")
|
|
666
|
+
plan.deletesBot = m[1];
|
|
667
|
+
m = /^\/api\/webhooks\/([\w-]+)(\/rotate)?$/.exec(path);
|
|
668
|
+
if (m && ((m[2] && method === "POST") || (!m[2] && (method === "PATCH" || method === "DELETE"))))
|
|
669
|
+
plan.webhookId = m[1];
|
|
670
|
+
m = /^\/api\/auth\/sessions\/([\w-]+)$/.exec(path);
|
|
671
|
+
if (m && method === "DELETE")
|
|
672
|
+
plan.sessionId = m[1];
|
|
673
|
+
m = /^\/api\/groups\/([\w-]+)$/.exec(path);
|
|
674
|
+
if (m && method === "PATCH")
|
|
675
|
+
plan.roomId = m[1];
|
|
676
|
+
m = /^\/api\/instances\/([\w.-]+)\/(install|auth\/complete|auth\/sign-out|claude-update)$/.exec(path);
|
|
677
|
+
if (m && method === "POST")
|
|
678
|
+
plan.engineAction = { id: m[1], action: m[2].replace("/", "-") };
|
|
679
|
+
if (method === "POST" && path === "/api/instances/claude-accounts")
|
|
680
|
+
plan.engineAction = { id: "claude", action: "account-add" };
|
|
681
|
+
return plan.config || plan.botId || plan.createsBots || plan.deletesBot || plan.createsWebhook || plan.webhookId || plan.sessionId || plan.pairing || plan.engineAction || plan.roomId ? plan : null;
|
|
682
|
+
}
|
|
683
|
+
const WEBHOOK_AUDIT_FIELDS = ["name", "prompt", "botId", "runOn", "enabled", "eventTypes"];
|
|
684
|
+
const auditBot = (id) => (id ? botAuditSnapshot(store.bot(id)) : null);
|
|
685
|
+
const auditHook = (id) => (id ? webhooks.list().find((hook) => hook.id === id) : undefined);
|
|
686
|
+
const hookFields = (hook, fields = WEBHOOK_AUDIT_FIELDS) => Object.fromEntries(fields.map((field) => [field, hook[field] ?? null]));
|
|
687
|
+
/** The bot fields a PATCH named: only those are compared, so a concurrent
|
|
688
|
+
* change to another field is not put down to this request. */
|
|
689
|
+
function requestedBotFields(req, path) {
|
|
690
|
+
if (path.endsWith("/always-allow"))
|
|
691
|
+
return ["alwaysAllow"];
|
|
692
|
+
if (path.endsWith("/model"))
|
|
693
|
+
return ["modelSelection"];
|
|
694
|
+
const body = parsedBodyOf(req);
|
|
695
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
696
|
+
return [];
|
|
697
|
+
const fields = new Set(Object.keys(body));
|
|
698
|
+
if (fields.has("autoApprove") || fields.has("approvalMode"))
|
|
699
|
+
fields.add("autoApprove").add("approvalMode");
|
|
700
|
+
if (fields.has("chiefOfStaff"))
|
|
701
|
+
fields.add("managedSections");
|
|
702
|
+
return [...fields];
|
|
703
|
+
}
|
|
704
|
+
/** Record this request's admin changes once it has answered successfully.
|
|
705
|
+
* Each row compares only what this request named or created, never a whole
|
|
706
|
+
* snapshot, so another admin's change made meanwhile is not attributed here. */
|
|
707
|
+
function beginAdminAudit(req, res, method, path, auth) {
|
|
708
|
+
const plan = DESKTOP_MANAGED ? null : adminAuditPlan(method, path);
|
|
709
|
+
const actor = plan ? adminActorFor(auth, req) : null;
|
|
710
|
+
// Whether several people share the workspace as the request begins: a
|
|
711
|
+
// change that ends sharing (the last chat-only phone signed out) is kept.
|
|
712
|
+
const sharedAtStart = plan ? adminActivityRecording() : false;
|
|
713
|
+
// Set for every request, so a kept-alive connection never inherits one.
|
|
714
|
+
adminActorScope.enterWith(plan?.config && actor ? { actor, sharedAtStart } : null);
|
|
715
|
+
if (!plan || !actor)
|
|
716
|
+
return;
|
|
717
|
+
const before = {
|
|
718
|
+
bot: auditBot(plan.botId ?? plan.deletesBot),
|
|
719
|
+
botName: store.bot(plan.botId ?? plan.deletesBot ?? "")?.name,
|
|
720
|
+
hook: auditHook(plan.webhookId),
|
|
721
|
+
session: plan.sessionId ? sessions.list().find((session) => session.id === plan.sessionId) : undefined,
|
|
722
|
+
roomFloor: plan.roomId ? store.group(plan.roomId)?.audienceFloor ?? "everyone" : undefined,
|
|
723
|
+
};
|
|
724
|
+
let answered;
|
|
725
|
+
if (plan.createsBots || plan.createsWebhook || plan.pairing)
|
|
726
|
+
onJsonBody(res, (body) => (answered = body));
|
|
727
|
+
res.once("finish", () => {
|
|
728
|
+
if (res.statusCode >= 400 || (!sharedAtStart && !adminActivityRecording()))
|
|
729
|
+
return;
|
|
730
|
+
try {
|
|
731
|
+
for (const row of adminAuditRows(plan, req, path, before, answered))
|
|
732
|
+
appendAdminAction(DATA_DIR, { ...row, actor });
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
console.warn(`admin activity: could not record ${method} ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
function adminAuditRows(plan, req, path, before, answered) {
|
|
740
|
+
const rows = [];
|
|
741
|
+
const reply = answered && typeof answered === "object" ? answered : {};
|
|
742
|
+
const idOf = (value) => (value && typeof value === "object" && typeof value.id === "string" ? value.id : undefined);
|
|
743
|
+
if (plan.botId) {
|
|
744
|
+
rows.push(...botChangeRows({ id: plan.botId, name: store.bot(plan.botId)?.name }, before.bot, auditBot(plan.botId), requestedBotFields(req, path)));
|
|
745
|
+
}
|
|
746
|
+
if (plan.createsBots) {
|
|
747
|
+
const created = [idOf(reply.bot), ...(Array.isArray(reply.bots) ? reply.bots.map(idOf) : [])].filter((id) => Boolean(id));
|
|
748
|
+
for (const id of new Set(created)) {
|
|
749
|
+
const fields = auditBot(id);
|
|
750
|
+
if (fields)
|
|
751
|
+
rows.push({ category: "bot", action: "bot.create", target: { kind: "bot", id, name: store.bot(id).name }, after: fields });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (plan.deletesBot && before.bot && !store.bot(plan.deletesBot)) {
|
|
755
|
+
rows.push({ category: "bot", action: "bot.delete", target: { kind: "bot", id: plan.deletesBot, ...(before.botName ? { name: before.botName } : {}) }, before: before.bot });
|
|
756
|
+
}
|
|
757
|
+
const hookTarget = (hook) => ({ kind: "webhook", id: hook.id, name: hook.name });
|
|
758
|
+
if (plan.createsWebhook) {
|
|
759
|
+
const hook = auditHook(idOf(reply.webhook));
|
|
760
|
+
if (hook)
|
|
761
|
+
rows.push({ category: "webhook", action: "webhook.create", target: hookTarget(hook), after: hookFields(hook) });
|
|
762
|
+
}
|
|
763
|
+
if (plan.webhookId && before.hook) {
|
|
764
|
+
const after = auditHook(plan.webhookId);
|
|
765
|
+
if (!after)
|
|
766
|
+
rows.push({ category: "webhook", action: "webhook.delete", target: hookTarget(before.hook), before: hookFields(before.hook) });
|
|
767
|
+
else if (path.endsWith("/rotate"))
|
|
768
|
+
rows.push({ category: "webhook", action: "webhook.rotate-secret", target: hookTarget(after) });
|
|
769
|
+
else {
|
|
770
|
+
const body = parsedBodyOf(req);
|
|
771
|
+
const named = body && typeof body === "object" ? Object.keys(body) : [];
|
|
772
|
+
const changed = WEBHOOK_AUDIT_FIELDS.filter((field) => named.includes(field) &&
|
|
773
|
+
JSON.stringify(hookFields(before.hook, [field])) !== JSON.stringify(hookFields(after, [field])));
|
|
774
|
+
if (changed.length) {
|
|
775
|
+
rows.push({ category: "webhook", action: "webhook.update", target: hookTarget(after), changed: [...changed], before: hookFields(before.hook, changed), after: hookFields(after, changed) });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
if (before.session && !sessions.list().some((session) => session.id === before.session.id)) {
|
|
780
|
+
const session = before.session;
|
|
781
|
+
rows.push({ category: "session", action: "session.revoke", target: { kind: "session", id: session.id, name: session.email ?? session.label },
|
|
782
|
+
before: { label: session.label, ...(session.email ? { email: session.email } : {}), scopes: session.scopes } });
|
|
783
|
+
}
|
|
784
|
+
if (plan.pairing) {
|
|
785
|
+
const pairing = sessions.openPairings().find((candidate) => candidate.id === reply.id);
|
|
786
|
+
if (pairing)
|
|
787
|
+
rows.push({ category: "session", action: "pairing.create", target: { kind: "pairing", name: pairing.label }, after: { label: pairing.label, scopes: pairing.scopes } });
|
|
788
|
+
}
|
|
789
|
+
if (plan.engineAction)
|
|
790
|
+
rows.push({ category: "engine", action: `engine.${plan.engineAction.action}`, target: { kind: "engine", id: plan.engineAction.id } });
|
|
791
|
+
const roomBody = plan.roomId ? parsedBodyOf(req) : undefined;
|
|
792
|
+
const room = plan.roomId ? store.group(plan.roomId) : undefined;
|
|
793
|
+
if (room && roomBody && typeof roomBody === "object" && roomBody.resetAudience === true) {
|
|
794
|
+
rows.push({ category: "visibility", action: "room.audience-reset", target: { kind: "room", id: room.id, name: room.name },
|
|
795
|
+
changed: ["audienceFloor"], before: { audienceFloor: before.roomFloor ?? "everyone" }, after: { audienceFloor: room.audienceFloor ?? "everyone" } });
|
|
796
|
+
}
|
|
797
|
+
return rows;
|
|
798
|
+
}
|
|
799
|
+
/** The guarded send contract external interfaces (the Slack worker) opt into.
|
|
800
|
+
* `onBehalfOf` is additive and advertised by /api/health
|
|
801
|
+
* (capabilities.guardedOnBehalfOf): it names the person a relay acts for, so
|
|
802
|
+
* the turn is booked to them rather than to this machine. Only this
|
|
803
|
+
* admin-scoped route reads it; every other send is booked to its own
|
|
804
|
+
* session. */
|
|
805
|
+
const guardedSendSchema = z.object({
|
|
806
|
+
threadId: z.string().regex(/^[\w-]+$/),
|
|
807
|
+
sendId: z.string().regex(/^[A-Za-z0-9_-]{16,80}$/),
|
|
808
|
+
text: z.string().min(1),
|
|
809
|
+
expectedActiveLeafId: z.string().regex(/^[\w-]+$/).nullable(),
|
|
810
|
+
expectedApprovalMode: z.enum(["ask", "full"]).optional(),
|
|
811
|
+
replyToId: z.string().optional(),
|
|
812
|
+
onBehalfOf: z.object({
|
|
813
|
+
email: z.string().trim().toLowerCase().max(254).pipe(z.email()).optional(),
|
|
814
|
+
name: z.string().trim().min(1).max(120).optional(),
|
|
815
|
+
}).strict().refine((who) => Boolean(who.email || who.name)).optional(),
|
|
816
|
+
}).strict();
|
|
817
|
+
/** Who a request is, for the usage ledger: a paired or signed-in person, or
|
|
818
|
+
* this machine. Captured when the message is accepted and carried with it. */
|
|
819
|
+
function usageTriggerFor(auth) {
|
|
820
|
+
return auth.kind === "session"
|
|
298
821
|
? { kind: "user", ...(auth.session.email ? { email: auth.session.email } : {}), label: auth.session.label }
|
|
299
|
-
: { kind: "owner" }
|
|
822
|
+
: { kind: "owner" };
|
|
823
|
+
}
|
|
824
|
+
/** The operator's price list, when the server may read it (`billing`). */
|
|
825
|
+
function operatorPrices() {
|
|
826
|
+
return entitled("billing") && cfg.billing?.prices && Object.keys(cfg.billing.prices).length ? cfg.billing.prices : null;
|
|
827
|
+
}
|
|
828
|
+
/** Write one settled turn to the ledger, estimating its cost when the
|
|
829
|
+
* engine reported tokens but no price, and let the spend cap see it at once.
|
|
830
|
+
* The first booking that takes the month past the warning threshold, or to
|
|
831
|
+
* the cap, notifies admins (once per month each; server/spend.ts). */
|
|
832
|
+
function bookTurnUsage(row) {
|
|
833
|
+
const booked = { ...row, ...ledgerCost(row, operatorPrices()), at: new Date().toISOString() };
|
|
834
|
+
// The append lands asynchronously; the cap counts the row from memory
|
|
835
|
+
// until it does, so the check below (and the next turn start) sees it.
|
|
836
|
+
noteSpend(DATA_DIR, booked, appendUsage(DATA_DIR, booked));
|
|
837
|
+
const state = spendState(cfg, DATA_DIR);
|
|
838
|
+
const alert = takeSpendAlert(DATA_DIR, state);
|
|
839
|
+
if (alert && state) {
|
|
840
|
+
broadcast({ kind: "notify", notification: buildSpendNotification({ id: row.botId, name: row.botName }, row.threadId, spendAlertText(alert, state)) }, { adminOnly: true });
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
/** A queued line from before triggers were stored with it: its sender, else
|
|
844
|
+
* the owner; a bot's own queued words set nothing. */
|
|
845
|
+
function queuedTurnTrigger(item) {
|
|
846
|
+
if (item.trigger)
|
|
847
|
+
return item.trigger;
|
|
848
|
+
if (item.peerAsk)
|
|
849
|
+
return undefined;
|
|
850
|
+
return item.sender ? { kind: "user", label: item.sender.name } : { kind: "owner" };
|
|
300
851
|
}
|
|
301
852
|
const providerAuthSessions = new ProviderAuthSessions();
|
|
302
|
-
await registry.load(
|
|
853
|
+
await registry.load(providerConfigs(), decorateHostedProvider);
|
|
303
854
|
const bundledSkills = loadBundledSkills();
|
|
304
855
|
const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
|
|
305
856
|
// SAFETY: Electron's utility-process runtime is the only environment that
|
|
@@ -395,10 +946,21 @@ const bus = new EventBus();
|
|
|
395
946
|
bus.attach(registry.instances());
|
|
396
947
|
let companyRuntimeReady;
|
|
397
948
|
const companyRuntimeStarted = new Promise(resolve => { companyRuntimeReady = resolve; });
|
|
949
|
+
/** The enrolled organisation's desktop policy: in memory only, read-only, and
|
|
950
|
+
* inert (null) unless Electron's enrolled parent sends one. */
|
|
951
|
+
const managedPolicy = new ManagedDesktopPolicy({ onChange: () => broadcast({ kind: "config", ...configStatus() }) });
|
|
952
|
+
/** Why the organisation refuses this instance for bots, or undefined. */
|
|
953
|
+
function policyModelRefusal(instance) {
|
|
954
|
+
const engine = BUILT_IN_DRIVERS.find(driver => driver.driverKind === instance.driverKind)?.metadata.displayName;
|
|
955
|
+
return managedPolicy.modelRefusal({ driverKind: instance.driverKind, displayName: engine }, managedDesktop.owns(instance.instanceId));
|
|
956
|
+
}
|
|
398
957
|
const managedDesktop = new ManagedDesktopProviders({
|
|
399
958
|
registry,
|
|
400
959
|
dataDirectory: DATA_DIR,
|
|
401
960
|
beforeReplace: stopCompanyInstances,
|
|
961
|
+
// Ids became stable across re-enrolment; carry old references forward once.
|
|
962
|
+
migrate: aliases => { store.renameInstances(new Map(aliases.map(({ from, to }) => [from, to]))); },
|
|
963
|
+
onAvailability: () => broadcast({ kind: "config", ...configStatus() }),
|
|
402
964
|
afterReplace: ids => {
|
|
403
965
|
for (const id of providerInstancesChanging)
|
|
404
966
|
if (managedDesktop.owns(id))
|
|
@@ -409,6 +971,29 @@ const managedDesktop = new ManagedDesktopProviders({
|
|
|
409
971
|
broadcast({ kind: "config", ...configStatus() });
|
|
410
972
|
},
|
|
411
973
|
});
|
|
974
|
+
utilityParentPort?.on("message", event => {
|
|
975
|
+
const message = event.data;
|
|
976
|
+
if (message?.type !== "openmausbot:managed-desktop-identity")
|
|
977
|
+
return;
|
|
978
|
+
const requestId = typeof message.requestId === "string" && message.requestId.length <= 100 ? message.requestId : undefined;
|
|
979
|
+
void companyRuntimeStarted.then(() => managedDesktop.migrateIdentity(message.identity)).then(() => true, () => false).then(ok => {
|
|
980
|
+
utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok });
|
|
981
|
+
});
|
|
982
|
+
});
|
|
983
|
+
utilityParentPort?.on("message", event => {
|
|
984
|
+
const message = event.data;
|
|
985
|
+
if (message?.type !== "openmausbot:managed-desktop-policy")
|
|
986
|
+
return;
|
|
987
|
+
const requestId = typeof message.requestId === "string" && message.requestId.length <= 100 ? message.requestId : undefined;
|
|
988
|
+
let ok = true;
|
|
989
|
+
try {
|
|
990
|
+
managedPolicy.apply(message.policy);
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
ok = false;
|
|
994
|
+
}
|
|
995
|
+
utilityParentPort.postMessage({ type: "openmausbot:managed-desktop-result", requestId, ok });
|
|
996
|
+
});
|
|
412
997
|
// Only Electron owns this port. There is deliberately no HTTP equivalent or
|
|
413
998
|
// config patch for its organization identity, endpoint, or model capability.
|
|
414
999
|
utilityParentPort?.on("message", event => {
|
|
@@ -431,6 +1016,46 @@ const internalCapabilities = new Map();
|
|
|
431
1016
|
const activeInternalGenerationByThread = new Map();
|
|
432
1017
|
const internalGenerationByProviderTurn = new ProviderTurnGenerationRegistry();
|
|
433
1018
|
const computerSelectionTurns = new Map();
|
|
1019
|
+
// A bot can also run outside this server — a Telegram gateway, a Slack bot,
|
|
1020
|
+
// any long-lived engine process the harness did not spawn. That process never
|
|
1021
|
+
// receives a turn-scoped capability, so it cannot ask or delegate to its peers.
|
|
1022
|
+
// `<data dir>/external-runtimes.json` maps bot ids to standing bearer tokens
|
|
1023
|
+
// and an explicit threadId. Missing bindings fail closed, even for one task.
|
|
1024
|
+
// A matching bearer resolves to an agents capability bound to that thread only —
|
|
1025
|
+
// depth 0, no skill authoring, no room coordination, no bot/room/thread
|
|
1026
|
+
// creation. Every other bearer still goes through the per-turn map above. The
|
|
1027
|
+
// file is read on demand, so adding or rotating a token needs no restart, and
|
|
1028
|
+
// it must not be readable by other users (mode 600).
|
|
1029
|
+
const EXTERNAL_RUNTIME_GENERATION = "external-runtime";
|
|
1030
|
+
const EXTERNAL_RUNTIMES_FILE = join(DATA_DIR, "external-runtimes.json");
|
|
1031
|
+
/** The standing capability is for peer comms only. Everything that creates or
|
|
1032
|
+
* changes state on this server (threads, bots, rooms, skills, memory, …) needs
|
|
1033
|
+
* a real turn, which an external runtime never has here, so the routes are an
|
|
1034
|
+
* allow-list rather than flags on the capability. */
|
|
1035
|
+
function externalRuntimeMayCall(method, path) {
|
|
1036
|
+
if (method === "GET")
|
|
1037
|
+
return path === "/api/internal/agents" || /^\/api\/internal\/delegations\/[\w-]+$/.test(path);
|
|
1038
|
+
return method === "POST" && (path === "/api/internal/ask-bot" || path === "/api/internal/delegate-bot");
|
|
1039
|
+
}
|
|
1040
|
+
function externalRuntimeCapability(header) {
|
|
1041
|
+
const grant = authorizeExternalRuntime(EXTERNAL_RUNTIMES_FILE, header, id => store.bot(id));
|
|
1042
|
+
if (!grant)
|
|
1043
|
+
return null;
|
|
1044
|
+
return {
|
|
1045
|
+
botId: grant.botId,
|
|
1046
|
+
threadId: grant.threadId,
|
|
1047
|
+
externalRuntime: grant,
|
|
1048
|
+
generation: EXTERNAL_RUNTIME_GENERATION,
|
|
1049
|
+
depth: 0,
|
|
1050
|
+
kind: "agents",
|
|
1051
|
+
skillAuthoring: false,
|
|
1052
|
+
createdBots: 0,
|
|
1053
|
+
openedThreads: 0,
|
|
1054
|
+
orphanExpiresAt: Number.MAX_SAFE_INTEGER,
|
|
1055
|
+
roomCoordination: false,
|
|
1056
|
+
ownThreadCreation: false,
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
434
1059
|
function beginInternalCapabilityGeneration(threadId, generation = randomUUID()) {
|
|
435
1060
|
const previous = activeInternalGenerationByThread.get(threadId);
|
|
436
1061
|
if (previous)
|
|
@@ -506,9 +1131,14 @@ function authorizedInternalCapability(header) {
|
|
|
506
1131
|
if (got.length === expected.length && timingSafeEqual(got, expected))
|
|
507
1132
|
return capability;
|
|
508
1133
|
}
|
|
509
|
-
return
|
|
1134
|
+
return externalRuntimeCapability(header);
|
|
510
1135
|
}
|
|
511
1136
|
function internalCapabilityIsActive(capability) {
|
|
1137
|
+
if (capability.generation === EXTERNAL_RUNTIME_GENERATION) {
|
|
1138
|
+
return Boolean(capability.externalRuntime && capability.externalRuntime.botId === capability.botId &&
|
|
1139
|
+
capability.externalRuntime.threadId === capability.threadId &&
|
|
1140
|
+
externalRuntimeIsActive(EXTERNAL_RUNTIMES_FILE, capability.externalRuntime, id => store.bot(id)));
|
|
1141
|
+
}
|
|
512
1142
|
const switching = computerSelectionTurns.get(capability.threadId);
|
|
513
1143
|
if ((capability.kind === "computer" || capability.kind === "browser") &&
|
|
514
1144
|
switching?.generation === capability.generation && switching.selected)
|
|
@@ -608,6 +1238,9 @@ class DirectTurnSetupCancelled extends Error {
|
|
|
608
1238
|
}
|
|
609
1239
|
const directTurnDispatchClaims = new Map();
|
|
610
1240
|
const directTurnGenerationByThread = new Map();
|
|
1241
|
+
// Only the latest direct request per thread is retained. A fresh user turn
|
|
1242
|
+
// replaces it; unknown control-plane continuations deliberately lose proof.
|
|
1243
|
+
const directRequestOwners = new Map();
|
|
611
1244
|
const directFollowupTurns = new ProviderTurnGenerationRegistry();
|
|
612
1245
|
const directFollowupSettlers = new Map();
|
|
613
1246
|
const directCoordinationSettlers = new Map();
|
|
@@ -668,6 +1301,11 @@ function releaseTurnResources(owner) {
|
|
|
668
1301
|
}
|
|
669
1302
|
}
|
|
670
1303
|
async function bindTurnComputer(owner, resource, exclusive = false) {
|
|
1304
|
+
// Every computer claim passes here: a kind the organisation disallows is
|
|
1305
|
+
// refused at claim time, whichever path selected it.
|
|
1306
|
+
const kind = computerKindForResource(resource), refusal = kind && managedPolicy.computerRefusal(kind);
|
|
1307
|
+
if (refusal)
|
|
1308
|
+
throw Object.assign(new Error(refusal), { code: "managed_policy" });
|
|
671
1309
|
const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
|
|
672
1310
|
turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
|
|
673
1311
|
let waitingMessage;
|
|
@@ -729,9 +1367,23 @@ function botAtThreadCapacity(botId) {
|
|
|
729
1367
|
// completion event has already cleared the stored busy flag.
|
|
730
1368
|
return store.tasks(botId).filter((task) => threadBusy(botId, task.threadId)).length >= maxConcurrentBotThreads(cfg);
|
|
731
1369
|
}
|
|
1370
|
+
/** A card waiting on the person still owns fresh coordinated work, even
|
|
1371
|
+
* when another thread slot is free. A sibling that is only working does not. */
|
|
1372
|
+
function recipientAwaitingPerson(botId, exceptThreadId) {
|
|
1373
|
+
return store.tasks(botId).some((task) => task.threadId !== exceptThreadId && task.activity === "waiting-on-you");
|
|
1374
|
+
}
|
|
732
1375
|
function hasDirectDispatch(botId) {
|
|
733
1376
|
return [...directTurnDispatchClaims.values()].some((claim) => claim.botId === botId);
|
|
734
1377
|
}
|
|
1378
|
+
/** Exactly what startTurn admits for a direct thread: the landing thread
|
|
1379
|
+
* itself free, a free capacity slot, and no live group turn. Never the
|
|
1380
|
+
* whole-bot busy flag — that is true whenever ANY thread is working,
|
|
1381
|
+
* waiting on a person, or has no signal. Delegations and the resume drains
|
|
1382
|
+
* share this test so peer and room work waits for a real slot, not total
|
|
1383
|
+
* bot idleness. */
|
|
1384
|
+
function canAdmitDirectTurn(botId, threadId) {
|
|
1385
|
+
return !threadBusy(botId, threadId) && !botAtThreadCapacity(botId) && !activeGroupTurnForBot(botId);
|
|
1386
|
+
}
|
|
735
1387
|
/** Routine and webhook dispatch shares startTurn's admission preconditions
|
|
736
1388
|
* instead of waiting for whole-bot idleness: a free thread slot and no
|
|
737
1389
|
* active group turn. A group turn blocks scheduled starts the same way it
|
|
@@ -754,6 +1406,12 @@ function requestedTaskBot(botId, rawThreadId) {
|
|
|
754
1406
|
return task;
|
|
755
1407
|
}
|
|
756
1408
|
async function interruptDirectThread(botId, threadId) {
|
|
1409
|
+
const requestOwner = directRequestOwners.get(threadId);
|
|
1410
|
+
if (requestOwner) {
|
|
1411
|
+
requestOwner.stopped = true;
|
|
1412
|
+
if (requestOwner.messageId)
|
|
1413
|
+
store.patchMessage(threadId, requestOwner.messageId, { requestCancelled: true });
|
|
1414
|
+
}
|
|
757
1415
|
// Stop belongs to the conversation it was pressed in. This bot's turn ends
|
|
758
1416
|
// and this conversation stops awaiting its teammates, so nothing resumes
|
|
759
1417
|
// into a stopped chat; assignments that never started are dropped. A
|
|
@@ -861,8 +1519,12 @@ function markDirectTurnDispatching(botId, claimId, threadId) {
|
|
|
861
1519
|
return true;
|
|
862
1520
|
}
|
|
863
1521
|
function clearDirectTurnDispatch(threadId, claimId) {
|
|
864
|
-
if (directTurnDispatchClaims.get(threadId)?.id === claimId)
|
|
1522
|
+
if (directTurnDispatchClaims.get(threadId)?.id === claimId) {
|
|
865
1523
|
directTurnDispatchClaims.delete(threadId);
|
|
1524
|
+
// A fast provider can settle before its dispatch ACK. Its first cleanup
|
|
1525
|
+
// still saw this reservation, so releasing it must retry the final fence.
|
|
1526
|
+
settleTrackedRequest(threadId);
|
|
1527
|
+
}
|
|
866
1528
|
}
|
|
867
1529
|
const compactionControllers = new Map();
|
|
868
1530
|
function cancelDirectTurnDispatch(botId, expectedThreadId) {
|
|
@@ -1112,8 +1774,15 @@ function askBotAndWait(targetBotId, message, depth, fromBotId, fromThreadId, tar
|
|
|
1112
1774
|
});
|
|
1113
1775
|
}
|
|
1114
1776
|
// New bots honor setup's saved choice; unconfigured workspaces prefer Claude.
|
|
1777
|
+
// While enrolled, a Company model that can run beats a signed-out personal
|
|
1778
|
+
// engine, and the organisation's policy is respected; otherwise inert.
|
|
1115
1779
|
async function defaultSelection() {
|
|
1116
|
-
|
|
1780
|
+
if (hostedModels)
|
|
1781
|
+
return hostedModels.select(cfg.defaultModelSelection);
|
|
1782
|
+
return selectDefaultModelSelection(await registry.describe(), cfg.defaultModelSelection, {
|
|
1783
|
+
company: (instanceId) => managedDesktop.owns(instanceId),
|
|
1784
|
+
refusal: (instance) => policyModelRefusal(instance),
|
|
1785
|
+
});
|
|
1117
1786
|
}
|
|
1118
1787
|
function checkedModelSelection(raw, current, requireAvailableModel = false) {
|
|
1119
1788
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
@@ -1130,6 +1799,8 @@ function checkedModelSelection(raw, current, requireAvailableModel = false) {
|
|
|
1130
1799
|
instanceId: value.instanceId.trim(),
|
|
1131
1800
|
model: value.model.trim(),
|
|
1132
1801
|
};
|
|
1802
|
+
if (hostedModels && !hostedModels.allows(selection))
|
|
1803
|
+
return { ok: false, status: 400, error: hostedModels.error() };
|
|
1133
1804
|
if (value.effort !== undefined) {
|
|
1134
1805
|
if (!isEffortLevel(value.effort)) {
|
|
1135
1806
|
return { ok: false, status: 400, error: `effort "${String(value.effort)}" is not recognized` };
|
|
@@ -1305,6 +1976,7 @@ let followupsReady = false;
|
|
|
1305
1976
|
const sendSequencer = new SendSequencer();
|
|
1306
1977
|
bootSelection = await defaultSelection();
|
|
1307
1978
|
store.seedIfEmpty();
|
|
1979
|
+
hostedModels?.reconcile(store);
|
|
1308
1980
|
// A committed profile cleanup means both its config deletion and bot-reference
|
|
1309
1981
|
// cleanup were intended to be durable. Reconcile stale secondary references
|
|
1310
1982
|
// before Electron can ACK and remove the journal: a crash between those writes
|
|
@@ -1336,9 +2008,7 @@ if (browserCleanupReferencesReconciled)
|
|
|
1336
2008
|
* than the desktop window did. Stripped here rather than at each call site
|
|
1337
2009
|
* so a new broadcast cannot forget. */
|
|
1338
2010
|
let activeCoordinationForThread = (_threadId) => false;
|
|
1339
|
-
const wireTask = (task) => activeCoordinationForThread(task.threadId) && !task.busy
|
|
1340
|
-
? { ...toWireTask(task), busy: true, activity: "working" }
|
|
1341
|
-
: toWireTask(task);
|
|
2011
|
+
const wireTask = (task) => ({ ...toWireTask(task), waitingForTeammates: activeCoordinationForThread(task.threadId) && !task.busy });
|
|
1342
2012
|
const wireBot = (bot) => {
|
|
1343
2013
|
const { resumeCursors: _resumeCursors, tasks, approvalGrant, lastProfileRequestId: _lastProfileRequestId, lastTeamSetupReceipt: _lastTeamSetupReceipt, ...rest } = bot;
|
|
1344
2014
|
// An elevated selection is inert until the desktop confirms its exact
|
|
@@ -1347,7 +2017,7 @@ const wireBot = (bot) => {
|
|
|
1347
2017
|
const visible = approvalGrant && !approvalGrant.threadOnly
|
|
1348
2018
|
? { ...rest, approvalMode: "ask", autoApprove: false }
|
|
1349
2019
|
: rest;
|
|
1350
|
-
return { ...visible,
|
|
2020
|
+
return { ...visible, waitingForTeammates: activeCoordinationForThread(bot.threadId) && !threadBusy(bot.id, bot.threadId),
|
|
1351
2021
|
avatarUrl: visible.avatarUrl ?? null, ...(tasks ? { tasks: tasks.map(wireTask) } : {}) };
|
|
1352
2022
|
};
|
|
1353
2023
|
/** The correlated private response carries the requested value so Electron
|
|
@@ -1417,7 +2087,7 @@ function previewSystemPrompt(bot) {
|
|
|
1417
2087
|
browser: previewPlan.computer === undefined ? false : previewPlan.browser,
|
|
1418
2088
|
}, { note: previewPlan.note }) },
|
|
1419
2089
|
{ id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? COMPOSIO_PROMPT : "" },
|
|
1420
|
-
{ id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(
|
|
2090
|
+
{ id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
|
|
1421
2091
|
{ id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
1422
2092
|
{ id: "coordination", label: "Team", text: agentsMounted && coordination ? ` ${coordination}` : "" },
|
|
1423
2093
|
{ id: "credential", label: "Credentials", text: agentsMounted ? CREDENTIAL_PROMPT : "" },
|
|
@@ -1583,8 +2253,10 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
1583
2253
|
const grantTarget = (bot) => bot.approvalGrant?.threadOnly
|
|
1584
2254
|
? store.projectBotForTask(bot.id, bot.approvalGrant.threadId) : bot;
|
|
1585
2255
|
const grantBusy = (bot) => bot.approvalGrant?.threadOnly
|
|
1586
|
-
? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy
|
|
1587
|
-
|
|
2256
|
+
? threadBusy(bot.id, bot.approvalGrant.threadId) : bot.busy ||
|
|
2257
|
+
Boolean(bot.approvalGrant?.allThreads && store.tasks(bot.id).some(task => threadBusy(bot.id, task.threadId)));
|
|
2258
|
+
const grantSupported = (bot, mode) => supportsApprovalMode(registry.cliTarget(grantTarget(bot)?.modelSelection.instanceId ?? "")?.driverKind, mode) &&
|
|
2259
|
+
(!bot.approvalGrant?.allThreads || store.tasks(bot.id).every(task => supportsApprovalMode(registry.cliTarget((task.modelSelection ?? bot.modelSelection).instanceId)?.driverKind, mode)));
|
|
1588
2260
|
const clearGrant = (bot) => store.patchBot(bot.id, {
|
|
1589
2261
|
...(!bot.approvalGrant?.threadOnly ? { approvalMode: "ask", autoApprove: false } : {}),
|
|
1590
2262
|
approvalGrant: undefined,
|
|
@@ -1617,10 +2289,14 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
1617
2289
|
!grantBusy(bot) &&
|
|
1618
2290
|
threadCanReceiveGrant(bot) &&
|
|
1619
2291
|
grantSupported(bot, mode)) {
|
|
1620
|
-
if (bot.approvalGrant.
|
|
2292
|
+
if (bot.approvalGrant.allThreads && mode === "full") {
|
|
2293
|
+
store.setAllThreadApprovalMode(botId, mode);
|
|
2294
|
+
}
|
|
2295
|
+
else if (bot.approvalGrant.threadId) {
|
|
1621
2296
|
store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
|
|
1622
2297
|
}
|
|
1623
|
-
|
|
2298
|
+
if (bot.approvalGrant)
|
|
2299
|
+
store.patchBot(botId, { approvalGrant: undefined });
|
|
1624
2300
|
postDesktopPrivateMessage({ type: "approval-trusted-mode-commit-result", requestId, ok: true, bot: wireBot(store.bot(botId)) });
|
|
1625
2301
|
}
|
|
1626
2302
|
else if (bot?.approvalGrant?.requestId === requestId) {
|
|
@@ -1801,6 +2477,22 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
1801
2477
|
}
|
|
1802
2478
|
const currentMode = approvalModeFor(existing);
|
|
1803
2479
|
const threadId = message.threadId;
|
|
2480
|
+
if (message.allThreads !== undefined && (typeof message.allThreads !== "boolean" ||
|
|
2481
|
+
(message.allThreads && (threadId !== undefined || message.threadOnly === true ||
|
|
2482
|
+
message.modelSelection !== undefined || message.updateBotDefault !== undefined ||
|
|
2483
|
+
(mode !== "full" && mode !== "ask"))))) {
|
|
2484
|
+
respond({ ok: false, error: "Invalid all-threads approval scope" });
|
|
2485
|
+
return true;
|
|
2486
|
+
}
|
|
2487
|
+
if (message.allThreads === true && mode === "ask") {
|
|
2488
|
+
const updated = store.setAllThreadApprovalMode(botId, "ask");
|
|
2489
|
+
void stopBotForEmergencyApprovalDowngrade(botId).then(() => respond({ ok: true, bot: wireBot(store.bot(botId) ?? updated) }), () => respond({ ok: false, error: "Approval was reset to Ask, but an active turn could not be stopped" }));
|
|
2490
|
+
return true;
|
|
2491
|
+
}
|
|
2492
|
+
if (message.allThreads === true && (existing.approvalGrant || store.tasks(botId).some(task => threadBusy(botId, task.threadId) || !supportsApprovalMode(registry.cliTarget((task.modelSelection ?? existing.modelSelection).instanceId)?.driverKind, mode)))) {
|
|
2493
|
+
respond({ ok: false, error: "Finish this bot's active turns and approval changes first. Every thread's provider must support Full access." });
|
|
2494
|
+
return true;
|
|
2495
|
+
}
|
|
1804
2496
|
if (message.threadOnly !== undefined && typeof message.threadOnly !== "boolean") {
|
|
1805
2497
|
respond({ ok: false, error: "Invalid thread approval scope" });
|
|
1806
2498
|
return true;
|
|
@@ -1848,7 +2540,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
1848
2540
|
return true;
|
|
1849
2541
|
}
|
|
1850
2542
|
try {
|
|
1851
|
-
store.switchTaskModel(botId, threadId, checked.selection, message.updateBotDefault, true);
|
|
2543
|
+
store.switchTaskModel(botId, threadId, checked.selection, message.updateBotDefault, true, hostedModels?.resetTask(target.modelSelection, checked.selection));
|
|
1852
2544
|
const fresh = { ...wireBot(store.bot(botId)), approvalMode: approvalModeFor(store.bot(botId)) };
|
|
1853
2545
|
broadcast({ kind: "bot", bot: fresh });
|
|
1854
2546
|
respond({ ok: true, bot: fresh });
|
|
@@ -1897,7 +2589,7 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
1897
2589
|
approvalMode: mode,
|
|
1898
2590
|
autoApprove: mode === "auto",
|
|
1899
2591
|
approvalGrant: mode === "full" || mode === "custom"
|
|
1900
|
-
? { requestId, mode, phase: "prepared", ...(typeof threadId === "string" ? { threadId } : {}) }
|
|
2592
|
+
? { requestId, mode, phase: "prepared", ...(typeof threadId === "string" ? { threadId } : {}), ...(message.allThreads === true ? { allThreads: true } : {}) }
|
|
1901
2593
|
: undefined,
|
|
1902
2594
|
});
|
|
1903
2595
|
if (!updated) {
|
|
@@ -2013,6 +2705,9 @@ function createChannel(value) {
|
|
|
2013
2705
|
if (!roster.ok)
|
|
2014
2706
|
throw Object.assign(new Error(roster.error), { status: 400 });
|
|
2015
2707
|
const { memberIds } = roster;
|
|
2708
|
+
const audienceConflict = roomAudienceConflict(memberIds);
|
|
2709
|
+
if (audienceConflict)
|
|
2710
|
+
throw Object.assign(new Error(audienceConflict), { status: 400 });
|
|
2016
2711
|
if (body.name !== undefined && typeof body.name !== "string") {
|
|
2017
2712
|
throw Object.assign(new Error("channel name must be a string"), { status: 400 });
|
|
2018
2713
|
}
|
|
@@ -2093,6 +2788,9 @@ function updateChannel(groupId, value) {
|
|
|
2093
2788
|
const roster = checkedMemberIds(body.memberIds);
|
|
2094
2789
|
if (!roster.ok)
|
|
2095
2790
|
throw Object.assign(new Error(roster.error.replace("channel", "room")), { status: 400 });
|
|
2791
|
+
const audienceConflict = roomAudienceConflict(roster.memberIds, existing.memberIds);
|
|
2792
|
+
if (audienceConflict)
|
|
2793
|
+
throw Object.assign(new Error(audienceConflict), { status: 400 });
|
|
2096
2794
|
const removedGoalLead = routines.listRoutines().some((routine) => routine.enabled &&
|
|
2097
2795
|
routine.target === "room-goal" &&
|
|
2098
2796
|
routine.groupId === existing.id &&
|
|
@@ -2151,7 +2849,18 @@ function updateChannel(groupId, value) {
|
|
|
2151
2849
|
patch.section = trimmed;
|
|
2152
2850
|
}
|
|
2153
2851
|
}
|
|
2852
|
+
// An admin's explicit "show this room to everyone its current bots allow":
|
|
2853
|
+
// the only way a room's floor widens (settleRoomFloor). Admin-only: a
|
|
2854
|
+
// member's PATCH is limited to display fields, and a Chief's never has it.
|
|
2855
|
+
const resetAudience = body.resetAudience !== undefined;
|
|
2856
|
+
if (resetAudience) {
|
|
2857
|
+
if (body.resetAudience !== true)
|
|
2858
|
+
throw Object.assign(new Error("resetAudience must be true"), { status: 400 });
|
|
2859
|
+
patch.audienceFloor = undefined;
|
|
2860
|
+
}
|
|
2154
2861
|
const group = store.patchGroup(groupId, patch);
|
|
2862
|
+
if (resetAudience)
|
|
2863
|
+
audienceChanged();
|
|
2155
2864
|
if (!group)
|
|
2156
2865
|
throw Object.assign(new Error("no such room"), { status: 404 });
|
|
2157
2866
|
return group;
|
|
@@ -2195,7 +2904,7 @@ function roomHandoffProblem(node, parent) {
|
|
|
2195
2904
|
return "Room work cannot cross the sender's section boundary";
|
|
2196
2905
|
if (source && group && source.id === group.id && parent.threadId !== node.threadId)
|
|
2197
2906
|
return "Same-room work must stay in the originating conversation";
|
|
2198
|
-
if (!peerAllowed(from, bot
|
|
2907
|
+
if (!peerAllowed(from, bot))
|
|
2199
2908
|
return "The recipient is not an allowed peer of the sender";
|
|
2200
2909
|
}
|
|
2201
2910
|
}
|
|
@@ -2233,12 +2942,17 @@ function outstandingAssignmentsPrompt(threadId) {
|
|
|
2233
2942
|
const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
2234
2943
|
validate: (node, parent) => roomHandoffProblem(node, parent) ??
|
|
2235
2944
|
(parent && store.bot(parent.botId)?.approvePeerComms && !fullAccessForSource(parent.botId, parent.threadId) && !node.approvalGranted ? "Sender now requires peer approval; submit a new approved request" : undefined),
|
|
2236
|
-
//
|
|
2237
|
-
//
|
|
2238
|
-
//
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2945
|
+
// A free slot admits fresh work beside a sibling that is actually running
|
|
2946
|
+
// (#1589). A card waiting on the person still holds fresh work (#1128).
|
|
2947
|
+
// An owed resume is not fresh work, so a sibling card must not starve it
|
|
2948
|
+
// (#1278). Never overlap the addressed thread, exceed capacity, or race a
|
|
2949
|
+
// group turn.
|
|
2950
|
+
busy: n => {
|
|
2951
|
+
if (n.groupId)
|
|
2952
|
+
return Boolean(store.bot(n.botId)?.busy || (store.group(n.groupId) && groupIsWorking(store.group(n.groupId))));
|
|
2953
|
+
const slot = threadBusy(n.botId, n.threadId) || botAtThreadCapacity(n.botId) || Boolean(activeGroupTurnForBot(n.botId));
|
|
2954
|
+
return n.status === "resume" ? slot : slot || recipientAwaitingPerson(n.botId, n.threadId);
|
|
2955
|
+
},
|
|
2242
2956
|
changed: (groupIds, directThreadIds) => {
|
|
2243
2957
|
for (const id of groupIds) {
|
|
2244
2958
|
const group = store.group(id);
|
|
@@ -2246,6 +2960,7 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
2246
2960
|
broadcast({ kind: "group", group: publicGroupState(group) });
|
|
2247
2961
|
}
|
|
2248
2962
|
for (const threadId of directThreadIds) {
|
|
2963
|
+
settleTrackedRequest(threadId);
|
|
2249
2964
|
const bot = store.botByThread(threadId);
|
|
2250
2965
|
if (bot)
|
|
2251
2966
|
broadcast({ kind: "bot", bot: wireBot(bot) });
|
|
@@ -2320,6 +3035,21 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
2320
3035
|
});
|
|
2321
3036
|
}
|
|
2322
3037
|
markInternalTurn(node.threadId);
|
|
3038
|
+
// The settle itself is quiet by design: both the delegated turn and
|
|
3039
|
+
// this resume are internal, so turn.completed raises neither the unread
|
|
3040
|
+
// flag nor a done frame. One chip plus one notification per settle
|
|
3041
|
+
// covers the gap; steers land inside the running turn and never add
|
|
3042
|
+
// more. Group settles already speak through the room's own flow.
|
|
3043
|
+
if (resumed && !group) {
|
|
3044
|
+
const settledFrom = [...new Set(roomHandoffs.children(node.id)
|
|
3045
|
+
.map(child => store.bot(child.botId)?.name ?? "Teammate"))];
|
|
3046
|
+
const who = settledFrom.length ? settledFrom.join(", ") : "delegated work";
|
|
3047
|
+
store.appendMessage(node.threadId, {
|
|
3048
|
+
role: "bot", kind: "activity",
|
|
3049
|
+
tool: { name: `Resumed with ${who} results, reviewing`, ok: true },
|
|
3050
|
+
});
|
|
3051
|
+
notify(buildNotification("delegation-settled", bot, node.threadId, `Results in from ${who}`, { avatarUrl: bot.avatarUrl }));
|
|
3052
|
+
}
|
|
2323
3053
|
if (sender && parent && isUnattended(sender.id, parent.threadId))
|
|
2324
3054
|
markUnattended(bot.id, node.threadId);
|
|
2325
3055
|
if (!group)
|
|
@@ -2343,6 +3073,8 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
2343
3073
|
}
|
|
2344
3074
|
void startTurn(bot.id, turnText, {
|
|
2345
3075
|
threadId: node.threadId, cardContinuation: true, commsDepth: MAX_COMMS_DEPTH,
|
|
3076
|
+
requestMessageId: directRequestOwners.get(node.threadId)?.generations.has(node.rootId)
|
|
3077
|
+
? directRequestOwners.get(node.threadId)?.messageId : undefined,
|
|
2346
3078
|
unattended: isUnattended(bot.id, node.threadId),
|
|
2347
3079
|
coordination: { id: node.id, resumed, settle: finish },
|
|
2348
3080
|
onDispatchError: error => finish({ ok: false, text: error }),
|
|
@@ -2677,6 +3409,9 @@ const groupWithThread = (group) => ({
|
|
|
2677
3409
|
// endpoints whose callers need the transcript (task create/switch, imports)
|
|
2678
3410
|
// still send their richer payload on top.
|
|
2679
3411
|
store.onChange((change) => {
|
|
3412
|
+
// Who owns which thread, and what each member may see, follow the fleet.
|
|
3413
|
+
if (change.type !== "message" && change.type !== "message.patch" && change.type !== "thread" && change.type !== "sections")
|
|
3414
|
+
forgetVisibility();
|
|
2680
3415
|
switch (change.type) {
|
|
2681
3416
|
case "sections":
|
|
2682
3417
|
broadcast({ kind: "sections", sections: store.sections });
|
|
@@ -2691,6 +3426,7 @@ store.onChange((change) => {
|
|
|
2691
3426
|
broadcast({ kind: "thread", threadId: change.threadId, activeLeafId: change.activeLeafId });
|
|
2692
3427
|
break;
|
|
2693
3428
|
case "thread.deleted":
|
|
3429
|
+
directRequestOwners.delete(change.threadId);
|
|
2694
3430
|
routines?.forgetRoutineRequestReceiptsForThread(change.threadId);
|
|
2695
3431
|
// A deleted destination must not strand an approval in an internal
|
|
2696
3432
|
// task. Keep each run's snapshot and expose its execution as fallback.
|
|
@@ -2705,6 +3441,10 @@ store.onChange((change) => {
|
|
|
2705
3441
|
const bot = store.bot(change.botId);
|
|
2706
3442
|
if (bot)
|
|
2707
3443
|
broadcast({ kind: "bot", bot: wireBot(bot) });
|
|
3444
|
+
// A new audience narrows the rooms this bot is in, for good.
|
|
3445
|
+
if (bot)
|
|
3446
|
+
for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id)))
|
|
3447
|
+
settleRoomFloor(group);
|
|
2708
3448
|
break;
|
|
2709
3449
|
}
|
|
2710
3450
|
case "bot.deleted":
|
|
@@ -2714,6 +3454,8 @@ store.onChange((change) => {
|
|
|
2714
3454
|
const group = store.group(change.groupId);
|
|
2715
3455
|
if (group)
|
|
2716
3456
|
broadcast({ kind: "group", group: publicGroupState(group) });
|
|
3457
|
+
if (group)
|
|
3458
|
+
settleRoomFloor(group);
|
|
2717
3459
|
break;
|
|
2718
3460
|
}
|
|
2719
3461
|
case "group.deleted":
|
|
@@ -2773,6 +3515,62 @@ function messagePage(threadId, limit, before) {
|
|
|
2773
3515
|
activeLeafId: store.activeLeaf(threadId),
|
|
2774
3516
|
};
|
|
2775
3517
|
}
|
|
3518
|
+
function guardedRequestSnapshot(botId, threadId, sendId) {
|
|
3519
|
+
if (!store.taskByThread(botId, threadId))
|
|
3520
|
+
throw requestConflict("No such conversation for this bot");
|
|
3521
|
+
const activeLeafId = store.activeLeaf(threadId);
|
|
3522
|
+
const messages = guardedRequestPath(store.messagesFor(threadId), activeLeafId, sendId);
|
|
3523
|
+
const messageId = messages[0].id;
|
|
3524
|
+
const owner = directRequestOwners.get(threadId);
|
|
3525
|
+
const busy = threadBusy(botId, threadId);
|
|
3526
|
+
const waiting = messages.some(requestNeedsInput) || roomHandoffs.activeDirect(threadId) ||
|
|
3527
|
+
[...pendingTeamSetupResumes.values()].some(entry => entry.request.threadId === threadId);
|
|
3528
|
+
const latestTerminal = messages.findLast(message => message.role === "bot" && message.kind === "text" && message.turnTerminal);
|
|
3529
|
+
const latestTurn = messages.findLast(message => message.turnId);
|
|
3530
|
+
const latestOutcome = messages.findLast(message => message.turnSucceeded !== undefined);
|
|
3531
|
+
// A restart retains proven transcript annotations, not an execution lease.
|
|
3532
|
+
// Unproven output or a different live owner always requires the workspace.
|
|
3533
|
+
const untracked = messages[0].requestCancelled || (owner && (owner.messageId !== messageId || owner.stopped)) ||
|
|
3534
|
+
(busy && !owner) || messages.slice(1).some(message => (message.turnId || (message.role === "bot" && message.kind === "text")) && message.requestMessageId !== messageId) ||
|
|
3535
|
+
(!busy && !waiting && (messages[0].requestPending !== false || !latestTerminal?.turnSucceeded || latestTurn?.turnId !== latestTerminal.turnId ||
|
|
3536
|
+
latestOutcome?.turnSucceeded !== true || (owner && owner.turnId !== latestTerminal.turnId)));
|
|
3537
|
+
const snapshot = {
|
|
3538
|
+
messageId, activeLeafId,
|
|
3539
|
+
phase: untracked ? "untracked" : waiting ? "waiting" : busy ? "working" : "settled",
|
|
3540
|
+
activeTurnId: busy ? owner?.turnId ?? null : null,
|
|
3541
|
+
executionId: owner?.generation ?? null,
|
|
3542
|
+
messages: messages.map(slimMessage),
|
|
3543
|
+
};
|
|
3544
|
+
if (Buffer.byteLength(JSON.stringify(snapshot), "utf8") > 1024 * 1024) {
|
|
3545
|
+
throw Object.assign(new Error("Open this request in the workspace; its transcript exceeds the response limit"), { status: 413 });
|
|
3546
|
+
}
|
|
3547
|
+
return snapshot;
|
|
3548
|
+
}
|
|
3549
|
+
/** A durable completion fence, not a replay queue. If the process dies while
|
|
3550
|
+
* a Chief is awaiting results, its earlier handoff must not become a final. */
|
|
3551
|
+
function settleTrackedRequest(threadId) {
|
|
3552
|
+
const owner = directRequestOwners.get(threadId);
|
|
3553
|
+
const bot = store.botByThread(threadId);
|
|
3554
|
+
if (!owner?.messageId || owner.stopped || !bot || threadBusy(bot.id, threadId) || roomHandoffs.activeDirect(threadId) ||
|
|
3555
|
+
[...pendingTeamSetupResumes.values()].some(entry => entry.request.threadId === threadId))
|
|
3556
|
+
return;
|
|
3557
|
+
// A failed coordination root never supplies the promised return, even if
|
|
3558
|
+
// its original provider successfully said it had assigned the work.
|
|
3559
|
+
if ([...roomHandoffs.nodes.values()].some(node => !node.parentId && node.threadId === threadId &&
|
|
3560
|
+
owner.generations.has(node.id) && node.status !== "completed"))
|
|
3561
|
+
return;
|
|
3562
|
+
const path = store.activePath(threadId);
|
|
3563
|
+
const source = path.findLast(message => message.role === "user");
|
|
3564
|
+
if (source?.id !== owner.messageId || source.requestCancelled || source.requestPending !== true)
|
|
3565
|
+
return;
|
|
3566
|
+
const messages = path.slice(path.indexOf(source) + 1);
|
|
3567
|
+
const terminal = messages.findLast(message => message.turnTerminal && message.role === "bot" && message.kind === "text");
|
|
3568
|
+
if (messages.some(requestNeedsInput) || terminal?.requestMessageId !== source.id || !terminal.turnSucceeded ||
|
|
3569
|
+
terminal.turnId !== owner.turnId || messages.findLast(message => message.turnId)?.turnId !== terminal.turnId ||
|
|
3570
|
+
messages.findLast(message => message.turnSucceeded !== undefined)?.turnSucceeded !== true)
|
|
3571
|
+
return;
|
|
3572
|
+
store.patchMessage(threadId, source.id, { requestPending: false });
|
|
3573
|
+
}
|
|
2776
3574
|
/** A bounded page centred on a known message, used when a search result is
|
|
2777
3575
|
* opened on a client that only hydrated the newest part of the transcript. */
|
|
2778
3576
|
function messageWindow(threadId, messageId, limit) {
|
|
@@ -2786,6 +3584,26 @@ function messageWindow(threadId, messageId, limit) {
|
|
|
2786
3584
|
return { messages: all.slice(start, stop).map(slimMessage), hasMore: start > 0 };
|
|
2787
3585
|
}
|
|
2788
3586
|
const sseClients = new Set();
|
|
3587
|
+
/** The frame at which a bot's audience last changed (see the SSE route). */
|
|
3588
|
+
let audienceChangedAt = 0;
|
|
3589
|
+
/** A bot's audience changed: end every member stream. Each reconnects at
|
|
3590
|
+
* once with its cursor, which now predates the change, so it is answered
|
|
3591
|
+
* with a fresh snapshot of exactly what that person may see. Admin and
|
|
3592
|
+
* owner streams are untouched. */
|
|
3593
|
+
function audienceChanged() {
|
|
3594
|
+
audienceChangedAt = lastSeq;
|
|
3595
|
+
for (const client of sseClients) {
|
|
3596
|
+
if (client.viewer.kind !== "member")
|
|
3597
|
+
continue;
|
|
3598
|
+
sseClients.delete(client);
|
|
3599
|
+
try {
|
|
3600
|
+
client.res.end();
|
|
3601
|
+
}
|
|
3602
|
+
catch {
|
|
3603
|
+
/* already gone */
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
}
|
|
2789
3607
|
function closeSessionStreams(sessionId) {
|
|
2790
3608
|
browserLive.closeForOwner(sessionId);
|
|
2791
3609
|
for (const client of sseClients) {
|
|
@@ -2836,7 +3654,9 @@ function cursorSeq(raw) {
|
|
|
2836
3654
|
const parsed = Number(seq);
|
|
2837
3655
|
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
2838
3656
|
}
|
|
2839
|
-
|
|
3657
|
+
/** `adminOnly` frames (a workspace spend notice) reach admin streams and
|
|
3658
|
+
* are withheld from client sessions, live and on replay. */
|
|
3659
|
+
function broadcast(payload, options = {}) {
|
|
2840
3660
|
// Membership may also change through fleet/CLI config writes. Close stale
|
|
2841
3661
|
// email streams before any further workspace data is delivered.
|
|
2842
3662
|
sessions.revalidateEmailSessions();
|
|
@@ -2845,25 +3665,50 @@ function broadcast(payload) {
|
|
|
2845
3665
|
const frame = `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...payload, seq })}\n\n`;
|
|
2846
3666
|
// Store both projections as immutable frames: live and reconnecting clients
|
|
2847
3667
|
// must receive the same filtered config without changing the admin event.
|
|
2848
|
-
const clientFrame =
|
|
2849
|
-
?
|
|
2850
|
-
:
|
|
3668
|
+
const clientFrame = options.adminOnly
|
|
3669
|
+
? null
|
|
3670
|
+
: kind === "config"
|
|
3671
|
+
? `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...configForAccess(payload, false), seq })}\n\n`
|
|
3672
|
+
: frame;
|
|
2851
3673
|
// Live desktop captures can each be hundreds of kilobytes and become stale
|
|
2852
3674
|
// as soon as the next one arrives. Keep their sequence slots so resume-gap
|
|
2853
3675
|
// detection stays honest, but never retain their base64 payloads.
|
|
2854
|
-
replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame });
|
|
3676
|
+
replayBuffer.push({ seq, kind, frame: kind === "screen" ? null : frame, clientFrame: kind === "screen" ? null : clientFrame, payload: kind === "screen" ? null : payload });
|
|
2855
3677
|
if (replayBuffer.length > REPLAY_MAX)
|
|
2856
3678
|
replayBuffer.shift();
|
|
2857
3679
|
for (const client of Array.from(sseClients)) {
|
|
2858
3680
|
if (!wants(client, kind))
|
|
2859
3681
|
continue;
|
|
3682
|
+
// Admin-only frames (clientFrame null) never reach members, and a member never falls back to the admin frame.
|
|
3683
|
+
const out = client.admin ? frame : clientFrame === null ? null : memberFrame(client, seq, payload, clientFrame);
|
|
3684
|
+
if (out === null)
|
|
3685
|
+
continue;
|
|
2860
3686
|
// Screen frames are replaceable and durable events are not: see
|
|
2861
3687
|
// ./sse-fanout.ts for the backpressure/bound decision this makes.
|
|
2862
|
-
if (deliverSseFrame(client, kind,
|
|
3688
|
+
if (deliverSseFrame(client, kind, out) === "disconnected") {
|
|
2863
3689
|
sseClients.delete(client);
|
|
2864
3690
|
}
|
|
2865
3691
|
}
|
|
2866
3692
|
}
|
|
3693
|
+
/** The frame a non-admin stream gets: the shared client frame, unless a bot
|
|
3694
|
+
* is restricted and this member may not see all of what the frame carries —
|
|
3695
|
+
* then narrowed (bot-visibility.ts), withdrawn, or nothing at all (null). */
|
|
3696
|
+
function memberFrame(client, seq, payload, clientFrame) {
|
|
3697
|
+
// A frame kept from clients altogether stays kept: never the admin copy.
|
|
3698
|
+
if (clientFrame === null)
|
|
3699
|
+
return null;
|
|
3700
|
+
if (client.viewer.kind === "all")
|
|
3701
|
+
return clientFrame;
|
|
3702
|
+
const visible = visibleTo(client.viewer);
|
|
3703
|
+
if (visible.everything) {
|
|
3704
|
+
noteSeen(payload, client.seen);
|
|
3705
|
+
return clientFrame;
|
|
3706
|
+
}
|
|
3707
|
+
const narrowed = frameForMember(payload, memberFrameContext(visible), client.seen);
|
|
3708
|
+
if (!narrowed)
|
|
3709
|
+
return null;
|
|
3710
|
+
return narrowed === payload ? clientFrame : `id: ${STREAM_ID}:${seq}\ndata: ${JSON.stringify({ ...narrowed, seq })}\n\n`;
|
|
3711
|
+
}
|
|
2867
3712
|
onSteeredQueueChange(() => broadcast({ kind: "bot.queued", queues: publicBotQueuedMessages() }));
|
|
2868
3713
|
// ── server-side event folding (upstream's ingestion worker, miniature) ──
|
|
2869
3714
|
// The canonical stream is the source of truth; the persisted transcript
|
|
@@ -3186,9 +4031,14 @@ bus.subscribe((event) => {
|
|
|
3186
4031
|
const line = turnOutcomeLine({ ok: event.ok, reply: reply?.text, stopReason: event.stopReason, tools });
|
|
3187
4032
|
if (!line)
|
|
3188
4033
|
return;
|
|
4034
|
+
// A room fewer people can see than this bot leaves no line in its log:
|
|
4035
|
+
// session_search would hand it back in a chat anyone who sees the bot has.
|
|
4036
|
+
const room = store.groupByThread(event.threadId);
|
|
4037
|
+
if (room && !roomFeedsBot(room, bot))
|
|
4038
|
+
return;
|
|
3189
4039
|
appendMemoryLog(bot.id, line, {
|
|
3190
4040
|
source: memorySourceLabel({
|
|
3191
|
-
room
|
|
4041
|
+
room,
|
|
3192
4042
|
task: store.taskByThread(bot.id, event.threadId),
|
|
3193
4043
|
threadId: event.threadId,
|
|
3194
4044
|
}),
|
|
@@ -3311,6 +4161,8 @@ async function scheduleTurnDigest(input) {
|
|
|
3311
4161
|
text: renderDigest(digest),
|
|
3312
4162
|
digest,
|
|
3313
4163
|
turnId: input.turnId,
|
|
4164
|
+
requestMessageId: activities.find(message => message.turnId === input.turnId && message.requestMessageId)?.requestMessageId,
|
|
4165
|
+
turnSucceeded: input.succeeded,
|
|
3314
4166
|
...(input.from ? { from: input.from } : {}),
|
|
3315
4167
|
}, { kind: "digest.append", key: `${input.threadId}:${input.turnId}` });
|
|
3316
4168
|
// This is derived from the native session's own work, not an unseen
|
|
@@ -3854,6 +4706,11 @@ async function selectableComputers(bot) {
|
|
|
3854
4706
|
catch (error) {
|
|
3855
4707
|
reason = error instanceof Error ? error.message : String(error);
|
|
3856
4708
|
}
|
|
4709
|
+
// Not offered at all when the organisation disallows it.
|
|
4710
|
+
const kind = surface === "local" ? "thisComputer" : surface === "vm" ? "localVm" : surface === "cloud" ? (bot.cloudBackend === "vps" ? "vps" : "box") : undefined;
|
|
4711
|
+
const managed = kind && managedPolicy.computerRefusal(kind);
|
|
4712
|
+
if (managed)
|
|
4713
|
+
return { surface, label: surfaceLabel(surface), available: false, ready: false, canStart: false, canCreate: false, reason: managed };
|
|
3857
4714
|
const available = ready || canStart || canCreate;
|
|
3858
4715
|
return { surface, label: surfaceLabel(surface), available, ready, canStart, canCreate, ...(!available ? { reason } : {}) };
|
|
3859
4716
|
}));
|
|
@@ -4118,7 +4975,11 @@ bus.subscribe((event) => {
|
|
|
4118
4975
|
return;
|
|
4119
4976
|
const speaker = group ? groupSpeakers.get(event.threadId) : undefined;
|
|
4120
4977
|
const pushMessage = (m) => {
|
|
4121
|
-
const
|
|
4978
|
+
const owner = bot ? directRequestOwners.get(event.threadId) : undefined;
|
|
4979
|
+
const proven = owner?.messageId && !owner.stopped && owner.generation === directTurnGenerationByThread.get(event.threadId) &&
|
|
4980
|
+
owner.turnId && (!event.turnId || event.turnId === owner.turnId);
|
|
4981
|
+
const message = store.appendMessage(event.threadId, group && m.role === "bot" ? { ...m, from: speaker }
|
|
4982
|
+
: proven ? { ...m, requestMessageId: owner.messageId } : m);
|
|
4122
4983
|
return message;
|
|
4123
4984
|
};
|
|
4124
4985
|
if (coordinatorVisibleText) {
|
|
@@ -4129,6 +4990,10 @@ bus.subscribe((event) => {
|
|
|
4129
4990
|
handoffs.onEvent(event);
|
|
4130
4991
|
if (event.turnId)
|
|
4131
4992
|
liveTurnByThread.set(event.threadId, event.turnId);
|
|
4993
|
+
const requestOwner = bot ? directRequestOwners.get(event.threadId) : undefined;
|
|
4994
|
+
if (requestOwner && event.turnId && !requestOwner.turnId && event.type === "turn.started" &&
|
|
4995
|
+
directTurnDispatchClaims.get(event.threadId)?.id === requestOwner.generation)
|
|
4996
|
+
requestOwner.turnId = event.turnId;
|
|
4132
4997
|
const liveTurnId = event.turnId ?? liveTurnByThread.get(event.threadId);
|
|
4133
4998
|
switch (event.type) {
|
|
4134
4999
|
case "turn.started":
|
|
@@ -4455,8 +5320,11 @@ bus.subscribe((event) => {
|
|
|
4455
5320
|
});
|
|
4456
5321
|
}
|
|
4457
5322
|
}
|
|
4458
|
-
if (completedTurnId)
|
|
4459
|
-
store.markTerminalAssistantMessage(event.threadId, completedTurnId);
|
|
5323
|
+
if (completedTurnId) {
|
|
5324
|
+
const terminal = store.markTerminalAssistantMessage(event.threadId, completedTurnId);
|
|
5325
|
+
if (terminal)
|
|
5326
|
+
store.patchMessage(event.threadId, terminal.id, { turnSucceeded: event.ok });
|
|
5327
|
+
}
|
|
4460
5328
|
const reply = lastReply.get(event.threadId) ?? "";
|
|
4461
5329
|
lastReply.delete(event.threadId);
|
|
4462
5330
|
// A run that broke — not one the person stopped, and not a routine's,
|
|
@@ -4506,6 +5374,7 @@ bus.subscribe((event) => {
|
|
|
4506
5374
|
drainTeamSetupResumes();
|
|
4507
5375
|
drainDelegationWakes();
|
|
4508
5376
|
}
|
|
5377
|
+
settleTrackedRequest(event.threadId);
|
|
4509
5378
|
};
|
|
4510
5379
|
// bank what this turn spent before the bot broadcast carries the
|
|
4511
5380
|
// task list to every window. The driver's own per-turn figure
|
|
@@ -4531,7 +5400,7 @@ bus.subscribe((event) => {
|
|
|
4531
5400
|
const measuredSelection = directTurnBots.get(event.threadId)?.modelSelection ?? selection;
|
|
4532
5401
|
store.patchTask(bot.id, event.threadId, { lastContextModel: Number.isFinite(lastContext?.tokens) && (lastContext?.tokens ?? 0) > 0
|
|
4533
5402
|
? `${measuredSelection.instanceId}:${measuredSelection.model}` : undefined });
|
|
4534
|
-
|
|
5403
|
+
bookTurnUsage({
|
|
4535
5404
|
botId: bot.id,
|
|
4536
5405
|
botName: bot.name,
|
|
4537
5406
|
threadId: event.threadId,
|
|
@@ -4548,12 +5417,12 @@ bus.subscribe((event) => {
|
|
|
4548
5417
|
? { kind: "bot", ...(settledTask?.openedBy?.botId ? { botId: settledTask.openedBy.botId } : {}) }
|
|
4549
5418
|
: turnTriggers.get(event.threadId) ?? { kind: "owner" },
|
|
4550
5419
|
});
|
|
4551
|
-
noteSpend(DATA_DIR, event.cost ?? null);
|
|
4552
5420
|
const digestSettled = completedTurnId ? scheduleTurnDigest({
|
|
4553
5421
|
botId: bot.id,
|
|
4554
5422
|
botName: bot.name,
|
|
4555
5423
|
threadId: event.threadId,
|
|
4556
5424
|
turnId: completedTurnId,
|
|
5425
|
+
succeeded: event.ok,
|
|
4557
5426
|
driverKind: registry.get(selection.instanceId)?.driverKind,
|
|
4558
5427
|
instanceId: selection.instanceId,
|
|
4559
5428
|
reply,
|
|
@@ -4616,7 +5485,7 @@ bus.subscribe((event) => {
|
|
|
4616
5485
|
const tokens = event.usage ?? lastReported;
|
|
4617
5486
|
const speakingBot = store.bot(speaker.botId);
|
|
4618
5487
|
const selection = speakingBot?.modelSelection;
|
|
4619
|
-
|
|
5488
|
+
bookTurnUsage({
|
|
4620
5489
|
botId: speaker.botId,
|
|
4621
5490
|
botName: speaker.name,
|
|
4622
5491
|
threadId: event.threadId,
|
|
@@ -4631,7 +5500,6 @@ bus.subscribe((event) => {
|
|
|
4631
5500
|
? { kind: "routine", routineId: routineRun.routineId, label: routineRun.routineName }
|
|
4632
5501
|
: turnTriggers.get(event.threadId) ?? { kind: "owner" },
|
|
4633
5502
|
});
|
|
4634
|
-
noteSpend(DATA_DIR, event.cost ?? null);
|
|
4635
5503
|
if (completedTurnId) {
|
|
4636
5504
|
void scheduleTurnDigest({
|
|
4637
5505
|
botId: speaker.botId, botName: speaker.name,
|
|
@@ -5252,15 +6120,13 @@ function retryDelegationsWaitingOn(botId) {
|
|
|
5252
6120
|
// Explicit idle releases (room/setup/reload/watchdog fallbacks) may not
|
|
5253
6121
|
// publish turn.completed. They free a waiting source continuation too.
|
|
5254
6122
|
drainDelegationWakes();
|
|
5255
|
-
// A bot still busy in one thread has nevertheless freed a slot:
|
|
5256
|
-
//
|
|
5257
|
-
//
|
|
5258
|
-
|
|
5259
|
-
if (
|
|
6123
|
+
// A bot still busy in one thread has nevertheless freed a slot: every
|
|
6124
|
+
// handoff waiting on it re-tests its own admission — a fresh-thread
|
|
6125
|
+
// handoff asks for any free slot, a classic one the standing thread's
|
|
6126
|
+
// startTurn admission — so neither waits for whole-bot idleness.
|
|
6127
|
+
if (store.bot(botId)?.busy === true && botAtThreadCapacity(botId))
|
|
5260
6128
|
return;
|
|
5261
|
-
const released =
|
|
5262
|
-
? releaseDelegationsWaitingOn(botId, (item) => item.targetThreadId !== undefined)
|
|
5263
|
-
: releaseDelegationsWaitingOn(botId);
|
|
6129
|
+
const released = releaseDelegationsWaitingOn(botId);
|
|
5264
6130
|
for (const waitingThread of released) {
|
|
5265
6131
|
drainThreadDelegations(waitingThread);
|
|
5266
6132
|
}
|
|
@@ -5308,7 +6174,7 @@ bus.subscribe((event) => {
|
|
|
5308
6174
|
function drainQueuedSends() {
|
|
5309
6175
|
if (!followupsReady)
|
|
5310
6176
|
return;
|
|
5311
|
-
drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended) =>
|
|
6177
|
+
drainSteeredMessages(store, (botId, threadId, prompt, userMessage, excludeIds, unattended, head) =>
|
|
5312
6178
|
// A plain attended turn — no automationSource, no comms depth: exactly
|
|
5313
6179
|
// what typing the same words into an idle bot would run. Self-opened
|
|
5314
6180
|
// work retains `unattended` and the message's bot-origin provenance
|
|
@@ -5317,8 +6183,10 @@ function drainQueuedSends() {
|
|
|
5317
6183
|
// from duplicating the last one, and excludeIds drops every drained
|
|
5318
6184
|
// line from the transcript-replay so they are not also in `prompt`.
|
|
5319
6185
|
new Promise((resolve, reject) => {
|
|
6186
|
+
// The drained turn is booked to whoever sent the first waiting line.
|
|
5320
6187
|
void startTurn(botId, prompt, {
|
|
5321
6188
|
threadId, userMessage, excludeMessageIds: excludeIds, unattended, onTurnSettled: resolve,
|
|
6189
|
+
trigger: queuedTurnTrigger(head),
|
|
5322
6190
|
}).catch((err) => {
|
|
5323
6191
|
store.appendMessage(threadId, {
|
|
5324
6192
|
role: "bot", kind: "activity",
|
|
@@ -5337,7 +6205,7 @@ function drainQueuedSends() {
|
|
|
5337
6205
|
}
|
|
5338
6206
|
/** Keep a person's words off the transcript until a direct-thread slot is
|
|
5339
6207
|
* available. Reuse the existing cancellable, idempotent composer queue. */
|
|
5340
|
-
async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender) {
|
|
6208
|
+
async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId, sender, trigger) {
|
|
5341
6209
|
const capacity = botAtThreadCapacity(botId);
|
|
5342
6210
|
if (capacity || threadBusy(botId, threadId) || parksBehindCoordination(botId, threadId)) {
|
|
5343
6211
|
const reason = capacity ? "capacity" : undefined;
|
|
@@ -5346,10 +6214,12 @@ async function startOrQueueDirectMessage(botId, threadId, text, replyTo, sendId,
|
|
|
5346
6214
|
sendId,
|
|
5347
6215
|
reason,
|
|
5348
6216
|
prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
|
|
6217
|
+
sender,
|
|
6218
|
+
trigger,
|
|
5349
6219
|
});
|
|
5350
6220
|
return { ok: true, queued: true, queueId: queued.id, threadId, reason };
|
|
5351
6221
|
}
|
|
5352
|
-
const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender });
|
|
6222
|
+
const message = await startTurn(botId, text, { threadId, replyTo, sendId, sender, trigger });
|
|
5353
6223
|
return { ok: true, threadId, message };
|
|
5354
6224
|
}
|
|
5355
6225
|
/** How many start_thread calls one turn may make. Same spirit as the
|
|
@@ -5619,6 +6489,8 @@ async function startTurn(botId, text, opts) {
|
|
|
5619
6489
|
const task = store.taskByThread(bot.id, threadId);
|
|
5620
6490
|
if (!task)
|
|
5621
6491
|
throw Object.assign(new Error("no such task"), { status: 404 });
|
|
6492
|
+
if (hostedModels && !hostedModels.allows(bot.modelSelection))
|
|
6493
|
+
throw Object.assign(new Error(hostedModels.error()), { status: 409 });
|
|
5622
6494
|
const plan = turnSurfacePlan(bot, opts?.runOn, threadId);
|
|
5623
6495
|
const instance = turnInstance(bot, opts?.runOn, threadId);
|
|
5624
6496
|
if (!instance) {
|
|
@@ -5626,6 +6498,9 @@ async function startTurn(botId, text, opts) {
|
|
|
5626
6498
|
? "the Cloud VM runner is unavailable — configure Box in App Settings"
|
|
5627
6499
|
: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`), { status: 409 });
|
|
5628
6500
|
}
|
|
6501
|
+
const policyRefusal = policyModelRefusal(instance);
|
|
6502
|
+
if (policyRefusal)
|
|
6503
|
+
throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
|
|
5629
6504
|
// Resolve only transport tags from this newly submitted text. The original
|
|
5630
6505
|
// string remains the durable message. Native-image providers get a
|
|
5631
6506
|
// path-free prompt and bounded inputs instead of needing a Read tool;
|
|
@@ -5700,6 +6575,10 @@ async function startTurn(botId, text, opts) {
|
|
|
5700
6575
|
sender: opts?.sender,
|
|
5701
6576
|
});
|
|
5702
6577
|
}
|
|
6578
|
+
// Admitted: every refusal above has passed and no other turn runs on this
|
|
6579
|
+
// thread, so this is the one moment the ledger's "who asked" may change.
|
|
6580
|
+
if (opts?.trigger)
|
|
6581
|
+
turnTriggers.set(threadId, opts.trigger);
|
|
5703
6582
|
// A card continuation neither starts nor ends the person's ask: it
|
|
5704
6583
|
// resumes the turn their last message began, so that record stands.
|
|
5705
6584
|
if (!opts?.cardContinuation) {
|
|
@@ -5722,6 +6601,22 @@ async function startTurn(botId, text, opts) {
|
|
|
5722
6601
|
const resourceOwner = { threadId, generation: dispatchClaimId };
|
|
5723
6602
|
turnResourceOwners.set(threadId, resourceOwner);
|
|
5724
6603
|
directTurnGenerationByThread.set(threadId, dispatchClaimId);
|
|
6604
|
+
let requestMessageId = opts?.cardContinuation ? opts.requestMessageId : userMessage.id;
|
|
6605
|
+
const previousRequest = directRequestOwners.get(threadId);
|
|
6606
|
+
const requestGenerations = requestMessageId && previousRequest?.messageId === requestMessageId
|
|
6607
|
+
? previousRequest.generations : new Set();
|
|
6608
|
+
if (requestGenerations.size >= 500) {
|
|
6609
|
+
requestGenerations.clear();
|
|
6610
|
+
requestMessageId = undefined;
|
|
6611
|
+
}
|
|
6612
|
+
requestGenerations.add(dispatchClaimId);
|
|
6613
|
+
directRequestOwners.set(threadId, { generation: dispatchClaimId, messageId: requestMessageId,
|
|
6614
|
+
generations: requestGenerations, turnId: null });
|
|
6615
|
+
// An unknown control-plane wake may invalidate a prior final, but it must
|
|
6616
|
+
// never acquire authority by guessing the latest user as its origin.
|
|
6617
|
+
const pendingSource = requestMessageId ?? store.activePath(threadId).findLast(message => message.role === "user")?.id;
|
|
6618
|
+
if (pendingSource)
|
|
6619
|
+
store.patchMessage(threadId, pendingSource, { requestPending: true });
|
|
5725
6620
|
if (opts?.coordination)
|
|
5726
6621
|
directCoordinationSettlers.set(dispatchClaimId, opts.coordination.settle);
|
|
5727
6622
|
// Ordinary sources need the same exact completion ownership as queued
|
|
@@ -5930,7 +6825,7 @@ async function startTurn(botId, text, opts) {
|
|
|
5930
6825
|
// composio — only to a driver that can mount them. Their tools are
|
|
5931
6826
|
// never pre-allowed, so every call rides the normal permission flow.
|
|
5932
6827
|
if (instance.adapter.capabilities.customMcp === true) {
|
|
5933
|
-
const custom =
|
|
6828
|
+
const custom = engineMcpServers(bot);
|
|
5934
6829
|
if (Object.keys(custom).length)
|
|
5935
6830
|
integrations.custom = custom;
|
|
5936
6831
|
}
|
|
@@ -6000,6 +6895,13 @@ async function startTurn(botId, text, opts) {
|
|
|
6000
6895
|
throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
|
|
6001
6896
|
}
|
|
6002
6897
|
const wants = plan.computer;
|
|
6898
|
+
// A place the organisation disallows is refused before anything is
|
|
6899
|
+
// prepared; Auto below simply skips disallowed places.
|
|
6900
|
+
const wantedKind = teamComputer ? "box" : wants === "local" ? "thisComputer" : wants === "vm" ? "localVm"
|
|
6901
|
+
: wants === "cloud" ? (cloudBackend === "vps" ? "vps" : "box") : undefined;
|
|
6902
|
+
const placeRefusal = wantedKind && managedPolicy.computerRefusal(wantedKind);
|
|
6903
|
+
if (placeRefusal)
|
|
6904
|
+
throw Object.assign(new Error(placeRefusal), { status: 409, code: "managed_policy" });
|
|
6003
6905
|
let previewCapture = null;
|
|
6004
6906
|
let browserCapture = null;
|
|
6005
6907
|
let computerKind = null;
|
|
@@ -6177,7 +7079,7 @@ async function startTurn(botId, text, opts) {
|
|
|
6177
7079
|
// A VPS is a local-agent computer mount, never a remote agent runner.
|
|
6178
7080
|
// Explicit Cloud may prepare/start it. Auto remains read-only unless
|
|
6179
7081
|
// the person explicitly opted this bot into remote lifecycle actions.
|
|
6180
|
-
if ((wants === "cloud" || wants === undefined) && cloudBackend === "vps") {
|
|
7082
|
+
if ((wants === "cloud" || (wants === undefined && managedPolicy.computerAllowed("vps"))) && cloudBackend === "vps") {
|
|
6181
7083
|
const unsupported = vps.vpsDriverError(instance.driverKind, mountsComputerMcp);
|
|
6182
7084
|
if (unsupported && wants === "cloud")
|
|
6183
7085
|
throw new Error(unsupported);
|
|
@@ -6253,7 +7155,7 @@ async function startTurn(botId, text, opts) {
|
|
|
6253
7155
|
// Auto reaches a Local VM this bot already has before it ever touches the
|
|
6254
7156
|
// host's own desktop: on a headless server that VM is the only desktop
|
|
6255
7157
|
// there is, and a person who prepared one meant it to be used.
|
|
6256
|
-
if (wants === undefined && !integrations.computer && !integrations.localComputer && await attachLocalVm(false))
|
|
7158
|
+
if (wants === undefined && !integrations.computer && !integrations.localComputer && managedPolicy.computerAllowed("localVm") && await attachLocalVm(false))
|
|
6257
7159
|
computerKind = "vm";
|
|
6258
7160
|
// An unattended run on a bot with a VPS configured never lands on the
|
|
6259
7161
|
// host's own desktop instead: a scheduled job clicking on someone's
|
|
@@ -6263,6 +7165,7 @@ async function startTurn(botId, text, opts) {
|
|
|
6263
7165
|
!integrations.localComputer &&
|
|
6264
7166
|
wants === undefined &&
|
|
6265
7167
|
!unattendedVps &&
|
|
7168
|
+
managedPolicy.computerAllowed("thisComputer") &&
|
|
6266
7169
|
shouldMountLocalComputer({
|
|
6267
7170
|
requested: undefined,
|
|
6268
7171
|
hostPlatform: process.platform,
|
|
@@ -6457,7 +7360,7 @@ async function startTurn(botId, text, opts) {
|
|
|
6457
7360
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
6458
7361
|
// what the bot said lately in its other conversations, so a task
|
|
6459
7362
|
// never redoes — or forgets — what another one already did
|
|
6460
|
-
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(
|
|
7363
|
+
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
|
|
6461
7364
|
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace }) },
|
|
6462
7365
|
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
|
|
6463
7366
|
{ id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
|
|
@@ -6507,7 +7410,7 @@ async function startTurn(botId, text, opts) {
|
|
|
6507
7410
|
systemStable: prompt.stable,
|
|
6508
7411
|
systemVolatile: prompt.volatile,
|
|
6509
7412
|
integrations,
|
|
6510
|
-
mcpFromUserConfig: claudeUserMcpEnabled(cfg),
|
|
7413
|
+
mcpFromUserConfig: claudeUserMcpEnabled(cfg) && !managedPolicy.restrictsMcp(),
|
|
6511
7414
|
cwd,
|
|
6512
7415
|
}), () => !directTurnClaimExists(bot.id, dispatchClaimId, threadId), async () => {
|
|
6513
7416
|
await instance.adapter.interruptTurn(threadId).catch(() => { });
|
|
@@ -6516,6 +7419,12 @@ async function startTurn(botId, text, opts) {
|
|
|
6516
7419
|
retireProviderTurn(dispatch.value.turnId);
|
|
6517
7420
|
throw new DirectTurnSetupCancelled("turn stopped during provider setup");
|
|
6518
7421
|
}
|
|
7422
|
+
const requestOwner = directRequestOwners.get(threadId);
|
|
7423
|
+
if (requestOwner?.generation === dispatchClaimId && dispatch.value.turnId) {
|
|
7424
|
+
if (requestOwner.turnId && requestOwner.turnId !== dispatch.value.turnId)
|
|
7425
|
+
requestOwner.messageId = undefined;
|
|
7426
|
+
requestOwner.turnId = dispatch.value.turnId;
|
|
7427
|
+
}
|
|
6519
7428
|
bindInternalCapabilityToProviderTurn(threadId, dispatchClaimId, dispatch.value.turnId);
|
|
6520
7429
|
handoffs.bindTurn(threadId, dispatchClaimId, dispatch.value.turnId);
|
|
6521
7430
|
if (directFollowupSettlers.has(dispatchClaimId) && dispatch.value.turnId &&
|
|
@@ -6601,6 +7510,7 @@ async function startTurn(botId, text, opts) {
|
|
|
6601
7510
|
store.appendMessage(threadId, {
|
|
6602
7511
|
role: "bot",
|
|
6603
7512
|
kind: "activity",
|
|
7513
|
+
turnSucceeded: false,
|
|
6604
7514
|
tool: { name: `error: ${message.slice(0, 160)}`, ok: false },
|
|
6605
7515
|
});
|
|
6606
7516
|
// Worth a buzz for the same reason a routine failure is, and the rule
|
|
@@ -6827,7 +7737,12 @@ async function stopBotForEmergencyApprovalDowngrade(botId) {
|
|
|
6827
7737
|
// Load queued handoffs before scheduler recovery can fail an interrupted
|
|
6828
7738
|
// run. Its failure callback can then durably drop that work immediately;
|
|
6829
7739
|
// nothing dispatches until the listener is ready below.
|
|
6830
|
-
const commsBus = {
|
|
7740
|
+
const commsBus = {
|
|
7741
|
+
store,
|
|
7742
|
+
broadcast,
|
|
7743
|
+
threadSlotFree: (botId) => !botAtThreadCapacity(botId),
|
|
7744
|
+
canAdmitDirectTurn,
|
|
7745
|
+
};
|
|
6831
7746
|
_loadPending();
|
|
6832
7747
|
routines = new RoutineManager({
|
|
6833
7748
|
emit: broadcast,
|
|
@@ -7301,7 +8216,7 @@ function dispatchTeamSetupResume(entry) {
|
|
|
7301
8216
|
const message = store.messagesFor(request.threadId).find((item) => item.id === messageId);
|
|
7302
8217
|
if (!owner || !message?.card?.teamSetupRequest?.result)
|
|
7303
8218
|
return;
|
|
7304
|
-
if (
|
|
8219
|
+
if (!canAdmitDirectTurn(request.botId, request.threadId)) {
|
|
7305
8220
|
pendingTeamSetupResumes.set(request.requestId, entry);
|
|
7306
8221
|
return;
|
|
7307
8222
|
}
|
|
@@ -7332,7 +8247,8 @@ function dispatchTeamSetupResume(entry) {
|
|
|
7332
8247
|
groupQueues.set(groupId, next.finally(() => finishGroupTurnOperation(groupId, operation)).catch((error) => failed(error instanceof Error ? error.message : String(error))));
|
|
7333
8248
|
return;
|
|
7334
8249
|
}
|
|
7335
|
-
void startTurn(request.botId, prompt, { threadId: request.threadId, cardContinuation: true,
|
|
8250
|
+
void startTurn(request.botId, prompt, { threadId: request.threadId, cardContinuation: true,
|
|
8251
|
+
requestMessageId: requestSourceForCard(store.messagesFor(request.threadId), messageId), onDispatchError: failed }).catch((error) => {
|
|
7336
8252
|
if (cancelled())
|
|
7337
8253
|
return;
|
|
7338
8254
|
if (isTurnAdmissionBlocked(error))
|
|
@@ -7343,8 +8259,7 @@ function dispatchTeamSetupResume(entry) {
|
|
|
7343
8259
|
}
|
|
7344
8260
|
function drainTeamSetupResumes() {
|
|
7345
8261
|
for (const [key, entry] of pendingTeamSetupResumes) {
|
|
7346
|
-
|
|
7347
|
-
if (owner?.group ? owner.bot.busy : threadBusy(entry.request.botId, entry.request.threadId) || activeGroupTurnForBot(entry.request.botId))
|
|
8262
|
+
if (!canAdmitDirectTurn(entry.request.botId, entry.request.threadId))
|
|
7348
8263
|
continue;
|
|
7349
8264
|
pendingTeamSetupResumes.delete(key);
|
|
7350
8265
|
dispatchTeamSetupResume(entry);
|
|
@@ -7689,8 +8604,9 @@ setupRetry = 0) {
|
|
|
7689
8604
|
onDispatchError?.(`${bot.name}'s provider account is being updated — try again shortly`);
|
|
7690
8605
|
return true;
|
|
7691
8606
|
}
|
|
7692
|
-
|
|
7693
|
-
|
|
8607
|
+
const roomPolicyRefusal = instance ? policyModelRefusal(instance) : undefined;
|
|
8608
|
+
if (!instance || roomPolicyRefusal) {
|
|
8609
|
+
const message = roomPolicyRefusal ?? `${bot.name}'s model is unavailable`;
|
|
7694
8610
|
store.appendMessage(threadId, {
|
|
7695
8611
|
role: "bot",
|
|
7696
8612
|
kind: "activity",
|
|
@@ -7804,7 +8720,7 @@ setupRetry = 0) {
|
|
|
7804
8720
|
}
|
|
7805
8721
|
// user-configured MCP servers: same gating as the 1:1 site above.
|
|
7806
8722
|
if (instance.adapter.capabilities.customMcp === true) {
|
|
7807
|
-
const custom =
|
|
8723
|
+
const custom = engineMcpServers(bot);
|
|
7808
8724
|
if (Object.keys(custom).length)
|
|
7809
8725
|
integrations.custom = custom;
|
|
7810
8726
|
}
|
|
@@ -7921,6 +8837,13 @@ setupRetry = 0) {
|
|
|
7921
8837
|
readyBot.browser !== false &&
|
|
7922
8838
|
instance.adapter.capabilities.browserMcp === true,
|
|
7923
8839
|
});
|
|
8840
|
+
// A place the organisation disallows is refused before anything is
|
|
8841
|
+
// provisioned or started, exactly as a bot thread refuses it.
|
|
8842
|
+
const roomKind = roomTeamComputer ? "box" : roomPlan.computer === "local" ? "thisComputer" : readyBot.computer === "vm" ? "localVm"
|
|
8843
|
+
: roomPlan.computer === "cloud" ? (turnProvider(readyBot) === "vps" ? "vps" : "box") : undefined;
|
|
8844
|
+
const roomPlaceRefusal = roomKind && managedPolicy.computerRefusal(roomKind);
|
|
8845
|
+
if (roomPlaceRefusal)
|
|
8846
|
+
throw Object.assign(new Error(roomPlaceRefusal), { code: "managed_policy" });
|
|
7924
8847
|
// The Computer engine runs on the Box; this computer has no tools it can
|
|
7925
8848
|
// reach, exactly as a bot thread refuses it.
|
|
7926
8849
|
if (roomPlan.computer === "local" && instance.driverKind === "boxAgent") {
|
|
@@ -8112,7 +9035,7 @@ setupRetry = 0) {
|
|
|
8112
9035
|
// brief can carry a private chat into the room; as with session_search
|
|
8113
9036
|
// (#754) the room is told, once per source thread, rather than the
|
|
8114
9037
|
// crossing being blocked.
|
|
8115
|
-
const recentLines = recentWork(
|
|
9038
|
+
const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId });
|
|
8116
9039
|
{
|
|
8117
9040
|
const crossing = claimRecallCrossings(threadId, recentLines.filter((line) => line.private).map((line) => line.threadId));
|
|
8118
9041
|
if (crossing.count) {
|
|
@@ -8254,7 +9177,7 @@ setupRetry = 0) {
|
|
|
8254
9177
|
systemVolatile: roomSystem.volatile,
|
|
8255
9178
|
cwd,
|
|
8256
9179
|
integrations,
|
|
8257
|
-
mcpFromUserConfig: claudeUserMcpEnabled(cfg),
|
|
9180
|
+
mcpFromUserConfig: claudeUserMcpEnabled(cfg) && !managedPolicy.restrictsMcp(),
|
|
8258
9181
|
...(instance.instanceId === readyBot.modelSelection.instanceId
|
|
8259
9182
|
? memberTurnSelection(readyBot.modelSelection)
|
|
8260
9183
|
: { model: instance.models.default }),
|
|
@@ -8778,6 +9701,9 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
8778
9701
|
via: options.via,
|
|
8779
9702
|
sender: options.sender,
|
|
8780
9703
|
});
|
|
9704
|
+
// Admitted (see startTurn): the room's speakers are booked to this sender.
|
|
9705
|
+
if (options.trigger)
|
|
9706
|
+
turnTriggers.set(threadId, options.trigger);
|
|
8781
9707
|
const titled = group.dm ? null : store.titleGroupTaskFromFirstMessage(group.id, text, threadId);
|
|
8782
9708
|
const snippet = titled?.title;
|
|
8783
9709
|
const archived = members.filter((member) => member.hidden);
|
|
@@ -8833,7 +9759,8 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
|
|
|
8833
9759
|
const titleBot = goalCoordinator ?? responders[0];
|
|
8834
9760
|
const titleInstance = registry.get(titleBot.modelSelection.instanceId);
|
|
8835
9761
|
const titleText = extractTurnImages(text).text;
|
|
8836
|
-
|
|
9762
|
+
// An engine the organisation disallows never receives the text, even for a title.
|
|
9763
|
+
if (titled && snippet && titleText.trim() && llmThreadTitlesEnabled(cfg) && titleInstance?.generateText && !policyModelRefusal(titleInstance)) {
|
|
8837
9764
|
void generateThreadTitle(titleInstance, titleText)
|
|
8838
9765
|
.then((title) => {
|
|
8839
9766
|
if (title)
|
|
@@ -8926,7 +9853,7 @@ function drainQueuedChannelSends() {
|
|
|
8926
9853
|
drainChannelMessages((groupId) => {
|
|
8927
9854
|
const group = store.group(groupId);
|
|
8928
9855
|
return group ? groupIsWorking(group) : false;
|
|
8929
|
-
}, ({ groupId, threadId, text, replyToId, sendId, mode, id, via }) => {
|
|
9856
|
+
}, ({ groupId, threadId, text, replyToId, sendId, mode, id, via, sender, trigger }) => {
|
|
8930
9857
|
const group = store.group(groupId);
|
|
8931
9858
|
const ownsThread = group?.dm
|
|
8932
9859
|
? group.threadId === threadId
|
|
@@ -8934,11 +9861,11 @@ function drainQueuedChannelSends() {
|
|
|
8934
9861
|
if (!group || !ownsThread)
|
|
8935
9862
|
return;
|
|
8936
9863
|
try {
|
|
8937
|
-
startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via, threadId });
|
|
9864
|
+
startGroupTurn(groupId, text, resolveReplyTarget(threadId, replyToId), sendId, mode, id, { via, threadId, sender, trigger: queuedTurnTrigger({ trigger, sender }) });
|
|
8938
9865
|
}
|
|
8939
9866
|
catch (error) {
|
|
8940
9867
|
if (!store.messagesFor(threadId).some((message) => message.queueId === id && message.role === "user")) {
|
|
8941
|
-
store.appendMessage(threadId, { role: "user", kind: "text", text, replyToId, sendId, channelMode: mode, queueId: id, via });
|
|
9868
|
+
store.appendMessage(threadId, { role: "user", kind: "text", text, replyToId, sendId, channelMode: mode, queueId: id, via, sender });
|
|
8942
9869
|
}
|
|
8943
9870
|
store.appendMessage(threadId, {
|
|
8944
9871
|
role: "bot",
|
|
@@ -8966,6 +9893,10 @@ function ensureCalendarCallRoom(call) {
|
|
|
8966
9893
|
let group = linked && sameCalendarRoster(linked, call.botIds) && !roomSetupPending(linked)
|
|
8967
9894
|
? linked
|
|
8968
9895
|
: undefined;
|
|
9896
|
+
// A call's room is a room like any other: one audience.
|
|
9897
|
+
const conflict = group ? null : roomAudienceConflict(call.botIds);
|
|
9898
|
+
if (conflict)
|
|
9899
|
+
throw Object.assign(new Error(conflict), { status: 400 });
|
|
8969
9900
|
group ??= store.createGroup(call.name, call.botIds, false, undefined, {
|
|
8970
9901
|
bulletin: "",
|
|
8971
9902
|
defaultResponder: { kind: "everyone" },
|
|
@@ -8980,7 +9911,14 @@ function deliverCalendarCall(call, scheduledFor) {
|
|
|
8980
9911
|
// Multi-bot entries are rooms and begin with the shared event prompt.
|
|
8981
9912
|
if (call.botIds.length < 2)
|
|
8982
9913
|
return;
|
|
8983
|
-
|
|
9914
|
+
let group;
|
|
9915
|
+
try {
|
|
9916
|
+
group = ensureCalendarCallRoom(call);
|
|
9917
|
+
}
|
|
9918
|
+
catch (error) {
|
|
9919
|
+
console.warn(`calendar call "${call.name}" did not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
9920
|
+
return;
|
|
9921
|
+
}
|
|
8984
9922
|
const text = [
|
|
8985
9923
|
`@everyone ${call.description.trim() || call.name}`,
|
|
8986
9924
|
...call.attachments.map((attachment) => `<${attachment.kind === "image" ? "attached-image" : "attached-file"} path="${escapeAttribute(attachment.path)}" name="${escapeAttribute(attachment.name)}" />`),
|
|
@@ -9018,6 +9956,8 @@ function connectorThread(botId, threadId) {
|
|
|
9018
9956
|
const bot = store.bot(botId);
|
|
9019
9957
|
if (!bot)
|
|
9020
9958
|
return null;
|
|
9959
|
+
if (bot.threadId === threadId)
|
|
9960
|
+
return { bot, group: undefined }; // a bot's own main chat
|
|
9021
9961
|
if (store.taskByThread(botId, threadId))
|
|
9022
9962
|
return { bot, group: undefined };
|
|
9023
9963
|
const group = store.groupByThread(threadId);
|
|
@@ -9386,7 +10326,7 @@ function dispatchConnectorResume(entry) {
|
|
|
9386
10326
|
return;
|
|
9387
10327
|
const names = entry.labels.join(", ");
|
|
9388
10328
|
const prompt = `OpenMausBot connection update: the user securely connected ${names}. Continue the task that paused for this connection. Do not ask them to connect it again.`;
|
|
9389
|
-
if (
|
|
10329
|
+
if (!canAdmitDirectTurn(entry.botId, entry.threadId)) {
|
|
9390
10330
|
pendingConnectorResumes.set(`${entry.threadId}:${entry.resumeKey}`, entry);
|
|
9391
10331
|
return;
|
|
9392
10332
|
}
|
|
@@ -9439,8 +10379,7 @@ function maybeResumeConnectors(botId, threadId, resumeKey) {
|
|
|
9439
10379
|
}
|
|
9440
10380
|
function drainConnectorResumes() {
|
|
9441
10381
|
for (const [key, entry] of pendingConnectorResumes) {
|
|
9442
|
-
|
|
9443
|
-
if (owner?.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId))
|
|
10382
|
+
if (!canAdmitDirectTurn(entry.botId, entry.threadId))
|
|
9444
10383
|
continue;
|
|
9445
10384
|
pendingConnectorResumes.delete(key);
|
|
9446
10385
|
dispatchConnectorResume(entry);
|
|
@@ -9513,7 +10452,7 @@ function dispatchSecretResume(entry) {
|
|
|
9513
10452
|
const prompt = entry.outcome === "provided"
|
|
9514
10453
|
? `OpenMausBot credential update: the user securely provided ${entry.label}. Continue the task that paused for it. You do not receive the secret and must not ask them to paste it into chat.`
|
|
9515
10454
|
: `OpenMausBot credential update: the user declined to provide ${entry.label}. Continue without it if possible, or briefly explain the limitation. Do not ask them to paste it into chat.`;
|
|
9516
|
-
if (
|
|
10455
|
+
if (!canAdmitDirectTurn(entry.botId, entry.threadId)) {
|
|
9517
10456
|
pendingSecretResumes.set(`${entry.threadId}:${entry.messageId}`, entry);
|
|
9518
10457
|
return;
|
|
9519
10458
|
}
|
|
@@ -9658,8 +10597,7 @@ async function provideSecretFromPhone(context, authenticatedDeviceId) {
|
|
|
9658
10597
|
}
|
|
9659
10598
|
function drainSecretResumes() {
|
|
9660
10599
|
for (const [key, entry] of pendingSecretResumes) {
|
|
9661
|
-
|
|
9662
|
-
if (owner?.group ? owner.bot.busy : threadBusy(entry.botId, entry.threadId) || activeGroupTurnForBot(entry.botId))
|
|
10600
|
+
if (!canAdmitDirectTurn(entry.botId, entry.threadId))
|
|
9663
10601
|
continue;
|
|
9664
10602
|
pendingSecretResumes.delete(key);
|
|
9665
10603
|
dispatchSecretResume(entry);
|
|
@@ -9847,20 +10785,38 @@ async function perBotLocalVmCountForModeChange() {
|
|
|
9847
10785
|
}
|
|
9848
10786
|
return existingPerBotLocalVmCount(runtime.runtime);
|
|
9849
10787
|
}
|
|
10788
|
+
/** What Settings needs about the edition: the features, and, once the key
|
|
10789
|
+
* is inside its warning window or grace period, when it lapses. The dates
|
|
10790
|
+
* reach admins only (configForAccess strips them for client sessions). */
|
|
10791
|
+
function editionForSettings(status) {
|
|
10792
|
+
const expiring = status.edition === "enterprise" && status.expiresAt && status.expiresInDays !== undefined &&
|
|
10793
|
+
(status.graceEndsAt !== undefined || status.expiresInDays <= LICENSE_WARN_DAYS);
|
|
10794
|
+
return {
|
|
10795
|
+
edition: status.edition,
|
|
10796
|
+
features: status.features,
|
|
10797
|
+
...(expiring ? { license: { expiresAt: status.expiresAt, expiresInDays: status.expiresInDays, ...(status.graceEndsAt ? { graceEndsAt: status.graceEndsAt } : {}) } } : {}),
|
|
10798
|
+
};
|
|
10799
|
+
}
|
|
9850
10800
|
function configStatus() {
|
|
10801
|
+
// off-by-default thread cleanup knobs stay absent so clients can tell
|
|
10802
|
+
// "unset" apart from any in-range value
|
|
10803
|
+
const eventLogMaxBytes = threadEventLogMaxBytes(cfg);
|
|
10804
|
+
const eventLogRetentionDays = threadEventLogRetentionDays(cfg);
|
|
9851
10805
|
return {
|
|
9852
10806
|
xai: { configured: Boolean(cfg.xai?.key) },
|
|
9853
10807
|
anthropic: { configured: Boolean(cfg.anthropic?.key) },
|
|
9854
10808
|
// a fleet agent on this server means Settings → Workspaces has something to drive
|
|
9855
10809
|
fleet: { available: fleetAvailable(fleetSocketPath()) },
|
|
9856
10810
|
// what this build is entitled to, so Settings shows only what works here
|
|
9857
|
-
edition: (
|
|
10811
|
+
edition: editionForSettings(editionStatus()),
|
|
9858
10812
|
// settings, not secrets: the cap and the operator's own price list
|
|
9859
10813
|
budgets: {
|
|
9860
10814
|
...(cfg.budgets?.monthlyUsd !== undefined ? { monthlyUsd: cfg.budgets.monthlyUsd } : {}),
|
|
9861
10815
|
...(cfg.budgets?.warnAtPercent !== undefined ? { warnAtPercent: cfg.budgets.warnAtPercent } : {}),
|
|
9862
10816
|
},
|
|
9863
10817
|
billing: { currency: cfg.billing?.currency ?? "USD", prices: cfg.billing?.prices ?? {} },
|
|
10818
|
+
// how long the approval decision log is kept, after env and default
|
|
10819
|
+
decisions: { retentionDays: decisionRetentionDays(cfg.decisions?.retentionDays) },
|
|
9864
10820
|
// the base URL is a setting, not a secret; the key stays write-only
|
|
9865
10821
|
openaiCompat: { configured: Boolean(cfg.openaiCompat?.key), url: cfg.openaiCompat?.url ?? "" },
|
|
9866
10822
|
composio: {
|
|
@@ -9876,10 +10832,16 @@ function configStatus() {
|
|
|
9876
10832
|
imageGen: avatarImageStatus(cfg),
|
|
9877
10833
|
// not a secret — the sidebar shows it
|
|
9878
10834
|
profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "" },
|
|
10835
|
+
// the enrolled organisation's read-only desktop policy; null when not enrolled
|
|
10836
|
+
managedPolicy: managedPolicy.summary(),
|
|
9879
10837
|
// not a secret — the settings picker shows it; "" = follow the system
|
|
9880
10838
|
language: cfg.language ?? "",
|
|
9881
10839
|
rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
|
|
9882
|
-
threads: {
|
|
10840
|
+
threads: {
|
|
10841
|
+
maxConcurrentPerBot: maxConcurrentBotThreads(cfg),
|
|
10842
|
+
...(eventLogMaxBytes !== null ? { eventLogMaxBytes } : {}),
|
|
10843
|
+
...(eventLogRetentionDays !== null ? { eventLogRetentionDays } : {}),
|
|
10844
|
+
},
|
|
9883
10845
|
localVm: {
|
|
9884
10846
|
mode: localVmMode(cfg),
|
|
9885
10847
|
maxInstances: localVmMaxInstances(cfg),
|
|
@@ -9913,6 +10875,8 @@ function configStatus() {
|
|
|
9913
10875
|
browserProfiles: cfg.browserProfiles ?? [],
|
|
9914
10876
|
// who may sign in with an emailed code (server/account-signin.ts)
|
|
9915
10877
|
signIn: { admins: cfg.signIn?.admins ?? [], members: cfg.signIn?.members ?? [] },
|
|
10878
|
+
// whether that list decides anything here, or the organisation's Admin does
|
|
10879
|
+
membership: workspaceMembership(),
|
|
9916
10880
|
};
|
|
9917
10881
|
}
|
|
9918
10882
|
function configForAccess(status, admin) {
|
|
@@ -9923,6 +10887,7 @@ function configForAccess(status, admin) {
|
|
|
9923
10887
|
// source objects.
|
|
9924
10888
|
return {
|
|
9925
10889
|
...status,
|
|
10890
|
+
edition: { edition: status.edition.edition, features: status.edition.features },
|
|
9926
10891
|
signIn: { admins: [], members: [] },
|
|
9927
10892
|
vps: { configured: status.vps.configured, sshAlias: "" },
|
|
9928
10893
|
profile: { name: status.profile.name, email: "" },
|
|
@@ -9930,7 +10895,15 @@ function configForAccess(status, admin) {
|
|
|
9930
10895
|
};
|
|
9931
10896
|
}
|
|
9932
10897
|
function mcpServerResponse() {
|
|
9933
|
-
|
|
10898
|
+
// While enrolled with custom servers off, say which servers stay configured
|
|
10899
|
+
// but never reach bots, and why. Nothing here is written to config.json.
|
|
10900
|
+
const policy = managedPolicy.current();
|
|
10901
|
+
const servers = listMcpServers(cfg.mcpServers).map(server => managedPolicy.mcpAllowed(server.name, "url" in server ? server.url : undefined) ? server : { ...server, managedBy: policy.organizationName });
|
|
10902
|
+
return { servers, ...(policy && !policy.mcp.allowCustom ? { managed: { organizationName: policy.organizationName, allowlist: policy.mcp.allowlist } } : {}) };
|
|
10903
|
+
}
|
|
10904
|
+
/** Adding or editing a server the organisation has not approved is refused. */
|
|
10905
|
+
function mcpPolicyRefusal(name, server) {
|
|
10906
|
+
return managedPolicy.mcpRefusal(name, "url" in server && typeof server.url === "string" ? server.url : undefined);
|
|
9934
10907
|
}
|
|
9935
10908
|
/** The fields a new server may set, in whichever shape the form sent — a
|
|
9936
10909
|
* command to run or a URL to reach. Absent keys stay absent, so the strict
|
|
@@ -9946,6 +10919,11 @@ function mcpServerBody(body) {
|
|
|
9946
10919
|
}
|
|
9947
10920
|
return out;
|
|
9948
10921
|
}
|
|
10922
|
+
/** Configured MCP servers that may reach this bot's engine. While enrolled,
|
|
10923
|
+
* the organisation's allow-list filters them; config.json is never changed. */
|
|
10924
|
+
function engineMcpServers(bot) {
|
|
10925
|
+
return managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers));
|
|
10926
|
+
}
|
|
9949
10927
|
function persistMcpServers(next) {
|
|
9950
10928
|
saveConfig({ mcpServers: next });
|
|
9951
10929
|
// Do not reload the provider fleet: integrations are assembled from cfg at
|
|
@@ -9955,25 +10933,31 @@ function persistMcpServers(next) {
|
|
|
9955
10933
|
cfg.mcpServers = next;
|
|
9956
10934
|
}
|
|
9957
10935
|
async function describeInstances() {
|
|
9958
|
-
const configs =
|
|
10936
|
+
const configs = providerConfigs();
|
|
9959
10937
|
return (await registry.describe()).map((instance) => {
|
|
9960
10938
|
const entry = configs[instance.instanceId];
|
|
9961
10939
|
const described = entry?.icon ? { ...instance, icon: entry.icon } : instance;
|
|
10940
|
+
if (hostedModels)
|
|
10941
|
+
return { ...described, readOnly: true,
|
|
10942
|
+
install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
|
|
10943
|
+
};
|
|
10944
|
+
const policyReason = policyModelRefusal(instance);
|
|
10945
|
+
const policy = policyReason ? { policy: { organizationName: managedPolicy.current().organizationName, reason: policyReason } } : {};
|
|
9962
10946
|
if (managedDesktop.owns(instance.instanceId))
|
|
9963
10947
|
return {
|
|
9964
|
-
...described, readOnly: true, managed: managedDesktop.info(instance.instanceId),
|
|
10948
|
+
...described, ...policy, readOnly: true, managed: managedDesktop.info(instance.instanceId),
|
|
9965
10949
|
install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
|
|
9966
10950
|
};
|
|
9967
10951
|
if (entry?.driver !== "claudeAgent")
|
|
9968
|
-
return described;
|
|
10952
|
+
return { ...described, ...policy };
|
|
9969
10953
|
try {
|
|
9970
10954
|
const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
|
|
9971
|
-
return { ...described, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
|
|
10955
|
+
return { ...described, ...policy, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
|
|
9972
10956
|
}
|
|
9973
10957
|
catch {
|
|
9974
10958
|
// A malformed saved config remains a repairable shadow, never takes
|
|
9975
10959
|
// the model picker down or offers a login for the wrong directory.
|
|
9976
|
-
return { ...described, install: { ...instance.install, signInCommand: undefined } };
|
|
10960
|
+
return { ...described, ...policy, install: { ...instance.install, signInCommand: undefined } };
|
|
9977
10961
|
}
|
|
9978
10962
|
});
|
|
9979
10963
|
}
|
|
@@ -10090,7 +11074,7 @@ async function reloadProviders() {
|
|
|
10090
11074
|
bus.detachAll();
|
|
10091
11075
|
try {
|
|
10092
11076
|
await registry.disposeAll();
|
|
10093
|
-
await registry.load(
|
|
11077
|
+
await registry.load(providerConfigs(), decorateHostedProvider);
|
|
10094
11078
|
// Personal providers are usable independently of the optional Company
|
|
10095
11079
|
// overlay. Subscribe them before restoring that overlay so a broken or
|
|
10096
11080
|
// expired Company runtime cannot leave the rebuilt personal fleet mute.
|
|
@@ -10183,11 +11167,6 @@ function serveStatic(res, path) {
|
|
|
10183
11167
|
}
|
|
10184
11168
|
}
|
|
10185
11169
|
}
|
|
10186
|
-
function json(res, status, body) {
|
|
10187
|
-
const data = JSON.stringify(body);
|
|
10188
|
-
res.writeHead(status, { "content-type": "application/json" });
|
|
10189
|
-
res.end(data);
|
|
10190
|
-
}
|
|
10191
11170
|
/** A store refusal is a client error with a status of its own (400 path,
|
|
10192
11171
|
* 409 conflict, 413 too large); a 409 also carries what is on disk now so
|
|
10193
11172
|
* the editor can show the bot's version instead of guessing. Anything else
|
|
@@ -10208,46 +11187,6 @@ function journalEntryForClient(botId, entry) {
|
|
|
10208
11187
|
const threadTitle = entry.threadId ? store.taskByThread(botId, entry.threadId)?.title : undefined;
|
|
10209
11188
|
return threadTitle ? { ...visible, threadTitle } : visible;
|
|
10210
11189
|
}
|
|
10211
|
-
function readBody(req, limit = 1_000_000) {
|
|
10212
|
-
return new Promise((resolve, reject) => {
|
|
10213
|
-
let data = "";
|
|
10214
|
-
let bytes = 0;
|
|
10215
|
-
let done = false;
|
|
10216
|
-
const fail = (status, msg) => {
|
|
10217
|
-
if (done)
|
|
10218
|
-
return;
|
|
10219
|
-
done = true;
|
|
10220
|
-
const err = Object.assign(new Error(msg), { status });
|
|
10221
|
-
reject(err);
|
|
10222
|
-
};
|
|
10223
|
-
req.on("data", (c) => {
|
|
10224
|
-
if (done)
|
|
10225
|
-
return;
|
|
10226
|
-
bytes += typeof c === "string" ? Buffer.byteLength(c) : c.length;
|
|
10227
|
-
if (bytes > limit) {
|
|
10228
|
-
// Keep draining the socket, but stop retaining attacker-controlled
|
|
10229
|
-
// bytes. Destroying the request here prevents the caller from
|
|
10230
|
-
// receiving the useful 413 response.
|
|
10231
|
-
return fail(413, "body too large");
|
|
10232
|
-
}
|
|
10233
|
-
data += c;
|
|
10234
|
-
});
|
|
10235
|
-
req.on("end", () => {
|
|
10236
|
-
if (done)
|
|
10237
|
-
return;
|
|
10238
|
-
let body;
|
|
10239
|
-
try {
|
|
10240
|
-
body = data ? JSON.parse(data) : {};
|
|
10241
|
-
}
|
|
10242
|
-
catch {
|
|
10243
|
-
return fail(400, "invalid JSON body");
|
|
10244
|
-
}
|
|
10245
|
-
done = true;
|
|
10246
|
-
resolve(body);
|
|
10247
|
-
});
|
|
10248
|
-
req.on("error", (e) => fail(400, e instanceof Error ? e.message : String(e)));
|
|
10249
|
-
});
|
|
10250
|
-
}
|
|
10251
11190
|
// Loopback-only enforcement: the harness runs on 127.0.0.1 but accepts
|
|
10252
11191
|
// requests from any loopback connection and any web page that DNS-rebinds
|
|
10253
11192
|
// onto it. Reject non-loopback Hosts outright (defeats rebinding) and
|
|
@@ -10262,7 +11201,7 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
|
10262
11201
|
const current = resolveRequestAuth(req, {
|
|
10263
11202
|
sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events",
|
|
10264
11203
|
url: new URL(req.url ?? "/", `http://localhost:${PORT}`),
|
|
10265
|
-
loopbackMutationToken: desktopMutationToken, companionMutationToken,
|
|
11204
|
+
loopbackMutationToken: desktopMutationToken, companionMutationToken, loopbackTrust: LOOPBACK.trust, cliOwnerToken,
|
|
10266
11205
|
}).auth;
|
|
10267
11206
|
return Boolean(current?.scopes.includes("admin") && current.kind === original.kind &&
|
|
10268
11207
|
(current.kind !== "session" || (original.kind === "session" && current.session.id === original.session.id)));
|
|
@@ -10286,13 +11225,16 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
|
|
|
10286
11225
|
pause: () => { routines?.stop(); calendarCalls?.stop(); watchdog.stop(); },
|
|
10287
11226
|
resume: () => { routines?.start(); calendarCalls?.start(); watchdog.start(); },
|
|
10288
11227
|
flush: async () => {
|
|
10289
|
-
await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR)]);
|
|
11228
|
+
await Promise.all([flushAllProfileHistory(), flushAllMemoryJournals(), flushUsageLedger(DATA_DIR), flushDecisionLog(DATA_DIR), flushAdminActivity(DATA_DIR)]);
|
|
10290
11229
|
// With writers gated and work idle, release our WAL connection for the
|
|
10291
11230
|
// consistent snapshot. Store reopens it lazily after maintenance.
|
|
10292
11231
|
closeMessageDb();
|
|
10293
11232
|
},
|
|
10294
11233
|
}, keepLocked),
|
|
10295
11234
|
});
|
|
11235
|
+
// Route modules (server/routes/README.md). `workspaceAccess` is assigned at
|
|
11236
|
+
// boot, after this line, so the dependency reads it per request.
|
|
11237
|
+
ROUTES.push(createHostedSlackRoutes({ bot: (id) => store.bot(id), hostedReady: () => Boolean(workspaceAccess) && entitled("admin") }));
|
|
10296
11238
|
const toolResults = new ToolResults();
|
|
10297
11239
|
const handleRequest = async (req, res) => {
|
|
10298
11240
|
let url;
|
|
@@ -10317,6 +11259,8 @@ const handleRequest = async (req, res) => {
|
|
|
10317
11259
|
return json(res, 503, { error: "Hosted workspace readiness is unavailable." });
|
|
10318
11260
|
}
|
|
10319
11261
|
res.setHeader(HOSTED_CONTRACT_HEADER, String(HOSTED_CONTRACT_VERSION));
|
|
11262
|
+
if (hostedModels)
|
|
11263
|
+
res.setHeader(HOSTED_MODEL_POLICY_HEADER, "1");
|
|
10320
11264
|
return json(res, 200, { ok: true, service: "openmausbot", membershipAuthority: "portal", workspace: hosted.workspace, ...HOSTED_CONTRACT_METADATA });
|
|
10321
11265
|
}
|
|
10322
11266
|
// Hosted workspaces have one sign-in authority. A missing optional layer
|
|
@@ -10333,6 +11277,13 @@ const handleRequest = async (req, res) => {
|
|
|
10333
11277
|
return json(res, 503, { error: "Workspace sign-in is unavailable." });
|
|
10334
11278
|
}
|
|
10335
11279
|
}
|
|
11280
|
+
// An enrolled organisation that turns remote access off refuses new pairing
|
|
11281
|
+
// codes and new remote sessions. Existing sessions and this app are unchanged.
|
|
11282
|
+
if (method === "POST" && ["/api/auth/pair", "/api/pair", "/api/auth/pairing", "/api/auth/email/start", "/api/auth/email/verify"].includes(path)) {
|
|
11283
|
+
const refusal = managedPolicy.remoteAccessRefusal();
|
|
11284
|
+
if (refusal)
|
|
11285
|
+
return json(res, 403, { error: refusal, code: "managed_policy" });
|
|
11286
|
+
}
|
|
10336
11287
|
// ── who is asking (server/request-auth.ts) ──────────────────────────
|
|
10337
11288
|
// Two public routes come first: what this server is, and turning a pairing
|
|
10338
11289
|
// code into a session. Everything else needs the loopback owner or a
|
|
@@ -10458,6 +11409,8 @@ const handleRequest = async (req, res) => {
|
|
|
10458
11409
|
loopbackMutationToken: desktopMutationToken,
|
|
10459
11410
|
companionMutationToken,
|
|
10460
11411
|
features: { sharedComputers: sharedComputersEnabled(cfg) },
|
|
11412
|
+
loopbackTrust: LOOPBACK.trust,
|
|
11413
|
+
cliOwnerToken,
|
|
10461
11414
|
});
|
|
10462
11415
|
// The browser's cookie carries the term it was set with, and the
|
|
10463
11416
|
// session's term slides on use (sessions.ts `renew`), so re-issue the
|
|
@@ -10494,6 +11447,20 @@ const handleRequest = async (req, res) => {
|
|
|
10494
11447
|
if (failure)
|
|
10495
11448
|
return json(res, failure.status, { error: failure.error });
|
|
10496
11449
|
}
|
|
11450
|
+
// Bot visibility: a member reaches a restricted bot, its threads, rooms,
|
|
11451
|
+
// routines and files exactly as they reach an id that does not exist.
|
|
11452
|
+
// Lists and live frames are narrowed where they are built, below.
|
|
11453
|
+
const visible = visibleTo(viewerFor(auth));
|
|
11454
|
+
if (!visible.everything) {
|
|
11455
|
+
const subject = pathSubject(path);
|
|
11456
|
+
if (subject && !subjectVisible(subject, visible))
|
|
11457
|
+
return json(res, 404, { error: notFoundFor(subject) });
|
|
11458
|
+
// Every bot a member is sent (a read receipt, a profile or display
|
|
11459
|
+
// edit, a new or switched task) comes without its audience list or
|
|
11460
|
+
// the teammates they cannot see, whichever route answers.
|
|
11461
|
+
onJsonBody(res, (body) => memberBody(body, visible));
|
|
11462
|
+
}
|
|
11463
|
+
beginAdminAudit(req, res, method, path, auth);
|
|
10497
11464
|
if (method === "POST" && path === "/api/workspace-backup/restore" && teamComputers.list().some(computer => computer.section !== null)) {
|
|
10498
11465
|
return json(res, 409, { error: "Unassign team computers before restoring a workspace; restored team names must not gain access to existing desktops" });
|
|
10499
11466
|
}
|
|
@@ -10504,10 +11471,14 @@ const handleRequest = async (req, res) => {
|
|
|
10504
11471
|
if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !path.startsWith("/api/shared-computers/") && !isWorkspaceBackupSessionControl(method, path)) {
|
|
10505
11472
|
releaseWorkspaceRequest = workspaceMaintenance.request();
|
|
10506
11473
|
}
|
|
11474
|
+
// New routes live in modules registered in server/routes/table.ts and
|
|
11475
|
+
// are tried here, behind the gate above; do not add route `if`s below.
|
|
11476
|
+
if (await dispatchRoutes(ROUTES, { req, res, url, path, method, auth, json, readBody }))
|
|
11477
|
+
return;
|
|
10507
11478
|
// ── sessions: who am I, tickets, pairing and revocation ─────────────
|
|
10508
11479
|
if (method === "GET" && path === "/api/auth/session") {
|
|
10509
11480
|
return json(res, 200, auth.kind === "loopback"
|
|
10510
|
-
? { kind: "loopback", scopes: auth.scopes, environmentId: ENVIRONMENT_ID }
|
|
11481
|
+
? { kind: "loopback", scopes: auth.scopes, environmentId: ENVIRONMENT_ID, ...(auth.trust ? { trust: auth.trust } : {}) }
|
|
10511
11482
|
: {
|
|
10512
11483
|
kind: "session",
|
|
10513
11484
|
id: auth.session.id,
|
|
@@ -10518,6 +11489,9 @@ const handleRequest = async (req, res) => {
|
|
|
10518
11489
|
environmentId: ENVIRONMENT_ID,
|
|
10519
11490
|
// the account behind the session, when it came from a sign-in
|
|
10520
11491
|
...(auth.session.email ? { email: auth.session.email } : {}),
|
|
11492
|
+
// a hosted team workspace: the web UI's first run skips the
|
|
11493
|
+
// desktop-only beats there. Absent everywhere else.
|
|
11494
|
+
...(HOSTED_WORKSPACE ? { hosted: true } : {}),
|
|
10521
11495
|
});
|
|
10522
11496
|
}
|
|
10523
11497
|
if (method === "POST" && path === "/api/auth/stream-ticket") {
|
|
@@ -10620,8 +11594,14 @@ const handleRequest = async (req, res) => {
|
|
|
10620
11594
|
sharedComputerControl.release(body.id);
|
|
10621
11595
|
else if (body.action === "renew")
|
|
10622
11596
|
sharedComputerControl.renew(body.id);
|
|
10623
|
-
else
|
|
11597
|
+
else {
|
|
11598
|
+
// The desktop's own screen, lent to another computer: the organisation's
|
|
11599
|
+
// "this computer" setting applies here as it does to bot turns.
|
|
11600
|
+
const refusal = managedPolicy.computerRefusal("thisComputer");
|
|
11601
|
+
if (refusal)
|
|
11602
|
+
return json(res, 403, { error: refusal, code: "managed_policy" });
|
|
10624
11603
|
sharedComputerControl.acquire(body.id);
|
|
11604
|
+
}
|
|
10625
11605
|
return json(res, 200, { ok: true });
|
|
10626
11606
|
}
|
|
10627
11607
|
// A paired desktop registers only its own outbound connector. A second,
|
|
@@ -10721,6 +11701,9 @@ const handleRequest = async (req, res) => {
|
|
|
10721
11701
|
if (!internalSender) {
|
|
10722
11702
|
return json(res, 401, { error: "unauthorized" });
|
|
10723
11703
|
}
|
|
11704
|
+
if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !externalRuntimeMayCall(method, path)) {
|
|
11705
|
+
return json(res, 403, { error: "an external runtime can only list, ask and delegate to its peers and read its delegations" });
|
|
11706
|
+
}
|
|
10724
11707
|
const requiredCapabilityKind = path === "/api/internal/hook"
|
|
10725
11708
|
? "hooks"
|
|
10726
11709
|
: path === "/api/internal/browser/mcp"
|
|
@@ -10842,6 +11825,14 @@ const handleRequest = async (req, res) => {
|
|
|
10842
11825
|
const result = toolResults.read(internalCapability, id, offset);
|
|
10843
11826
|
return result ? json(res, 200, result) : json(res, 404, { error: "Saved result unavailable in this bot's conversation, expired, or offset out of range. Do not repeat an action to retrieve its output." });
|
|
10844
11827
|
}
|
|
11828
|
+
// Notes written from a room fewer people can see than this bot would
|
|
11829
|
+
// carry that room's words to everyone who can see the bot.
|
|
11830
|
+
if ((path === "/api/internal/memory" || path === "/api/internal/memory/log") && method === "POST") {
|
|
11831
|
+
const room = store.groupByThread(internalCapability.threadId);
|
|
11832
|
+
if (room && !roomFeedsBot(room, internalSender)) {
|
|
11833
|
+
return json(res, 403, { error: "This room is visible to fewer people than you are, so notes from it can't go into your memory. Keep them in the room." });
|
|
11834
|
+
}
|
|
11835
|
+
}
|
|
10845
11836
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
10846
11837
|
const body = await readInternalBody();
|
|
10847
11838
|
const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText }, { source: memorySource() });
|
|
@@ -11165,7 +12156,7 @@ const handleRequest = async (req, res) => {
|
|
|
11165
12156
|
return json(res, 403, { error: "Only an active Chief may plan team setup" });
|
|
11166
12157
|
return json(res, 200, {
|
|
11167
12158
|
teams: teamSetupTeams().filter((name) => canAccessTeam(chief, name)),
|
|
11168
|
-
bots: store.bots.filter((bot) => !bot.hidden && canAccessTeam(chief, bot.section) && (bot.id === chief.id || peerAllowed(chief, bot
|
|
12159
|
+
bots: store.bots.filter((bot) => !bot.hidden && canAccessTeam(chief, bot.section) && (bot.id === chief.id || peerAllowed(chief, bot)))
|
|
11169
12160
|
.map((bot) => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section ?? "", modelSelection: bot.modelSelection })),
|
|
11170
12161
|
instances: instances.map((instance) => ({ instanceId: instance.instanceId, driverKind: instance.driverKind, displayName: instance.displayName,
|
|
11171
12162
|
state: instance.snapshot.state, models: instance.models, effortLevels: instance.capabilities?.effortLevels ?? [] })),
|
|
@@ -11240,7 +12231,7 @@ const handleRequest = async (req, res) => {
|
|
|
11240
12231
|
const sinceRaw = url.searchParams.get("since");
|
|
11241
12232
|
const untilRaw = url.searchParams.get("until");
|
|
11242
12233
|
const since = sinceRaw ? parseSince(sinceRaw, now) : null;
|
|
11243
|
-
const until = untilRaw ?
|
|
12234
|
+
const until = untilRaw ? parseUntil(untilRaw, now) : null;
|
|
11244
12235
|
if (sinceRaw && since === null)
|
|
11245
12236
|
return json(res, 400, { error: "since must be a date, or a span like 24h, 3d, today, yesterday" });
|
|
11246
12237
|
if (untilRaw && until === null)
|
|
@@ -11268,7 +12259,9 @@ const handleRequest = async (req, res) => {
|
|
|
11268
12259
|
// Still own-bot: another bot's threads never enter this list.
|
|
11269
12260
|
const roomByThread = new Map();
|
|
11270
12261
|
for (const group of store.groups) {
|
|
11271
|
-
|
|
12262
|
+
// A room fewer people can see than this bot stays out of its recall
|
|
12263
|
+
// (bot-visibility.ts roomFeeds): its lines would otherwise reach them.
|
|
12264
|
+
if (!group.memberIds.includes(from.id) || !roomFeedsBot(group, from))
|
|
11272
12265
|
continue;
|
|
11273
12266
|
roomByThread.set(group.threadId, group);
|
|
11274
12267
|
for (const task of group.tasks ?? [])
|
|
@@ -11461,7 +12454,7 @@ const handleRequest = async (req, res) => {
|
|
|
11461
12454
|
// The sender's allow-list, when it has one. Checked here rather than
|
|
11462
12455
|
// trusted from the roster: the tool call carries a bot id, and an id
|
|
11463
12456
|
// the model held from an earlier turn must not outlive the grant.
|
|
11464
|
-
if (!peerAllowed(from, target
|
|
12457
|
+
if (!peerAllowed(from, target)) {
|
|
11465
12458
|
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
11466
12459
|
}
|
|
11467
12460
|
const fromThreadId = internalCapability.threadId;
|
|
@@ -11484,6 +12477,12 @@ const handleRequest = async (req, res) => {
|
|
|
11484
12477
|
const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted }, MAX_COMMS_DEPTH, fromThreadId);
|
|
11485
12478
|
if (queued.result !== "ok" || !queued.id)
|
|
11486
12479
|
return json(res, 200, { busy: true });
|
|
12480
|
+
// An external caller has no source turn whose completion can
|
|
12481
|
+
// begin the busy wait. Drain now so the target's idle release
|
|
12482
|
+
// retries this handoff, retaining any approval already granted.
|
|
12483
|
+
if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
|
|
12484
|
+
drainThreadDelegations(fromThreadId);
|
|
12485
|
+
}
|
|
11487
12486
|
return json(res, 200, { busy: true, taskId: queued.id, toBotName: target.name });
|
|
11488
12487
|
};
|
|
11489
12488
|
if (target.busy)
|
|
@@ -11515,7 +12514,7 @@ const handleRequest = async (req, res) => {
|
|
|
11515
12514
|
if (!canAccessTeam(freshFrom, freshTarget.section) || freshTarget.hidden) {
|
|
11516
12515
|
return json(res, 200, { error: "that bot moved to a different section" });
|
|
11517
12516
|
}
|
|
11518
|
-
if (!peerAllowed(freshFrom, freshTarget
|
|
12517
|
+
if (!peerAllowed(freshFrom, freshTarget)) {
|
|
11519
12518
|
return json(res, 200, { error: "that bot is no longer an allowed peer" });
|
|
11520
12519
|
}
|
|
11521
12520
|
// Membership can be revoked while the card is open: re-check the
|
|
@@ -11604,11 +12603,17 @@ const handleRequest = async (req, res) => {
|
|
|
11604
12603
|
// Bounded long-poll: the delegating bot parks ONE cheap HTTP request
|
|
11605
12604
|
// here instead of burning a model inference per status check.
|
|
11606
12605
|
for (;;) {
|
|
12606
|
+
requireActiveInternalCapability();
|
|
11607
12607
|
const receipt = findDelegationReceipt(taskId);
|
|
11608
12608
|
if (receipt) {
|
|
11609
12609
|
if (receipt.sourceThreadId !== fromThreadId) {
|
|
11610
12610
|
return json(res, 403, { error: "that task belongs to a different conversation" });
|
|
11611
12611
|
}
|
|
12612
|
+
const sender = store.bot(from.id);
|
|
12613
|
+
const target = store.bot(receipt.toBotId);
|
|
12614
|
+
if (!sender || (target && !canReachPeer(sender, target))) {
|
|
12615
|
+
return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
|
|
12616
|
+
}
|
|
11612
12617
|
return json(res, 200, { status: receipt.status, toBotName: receipt.toBotName, result: receipt.result ?? "" });
|
|
11613
12618
|
}
|
|
11614
12619
|
const stillQueued = pendingDelegationInfo(taskId);
|
|
@@ -11619,8 +12624,13 @@ const handleRequest = async (req, res) => {
|
|
|
11619
12624
|
return json(res, 404, { error: "unknown task id — delegation receipts are kept for about 48 hours" });
|
|
11620
12625
|
if (owner !== fromThreadId)
|
|
11621
12626
|
return json(res, 403, { error: "that task belongs to a different conversation" });
|
|
12627
|
+
const toBotId = stillQueued?.toBotId ?? running?.toBotId ?? "";
|
|
12628
|
+
const sender = store.bot(from.id);
|
|
12629
|
+
const target = store.bot(toBotId);
|
|
12630
|
+
if (!sender || (target && !canReachPeer(sender, target))) {
|
|
12631
|
+
return json(res, 403, { error: "Result withheld: team access changed while the teammate was working" });
|
|
12632
|
+
}
|
|
11622
12633
|
if (Date.now() >= deadline) {
|
|
11623
|
-
const toBotId = stillQueued?.toBotId ?? running?.toBotId ?? "";
|
|
11624
12634
|
if (running && runningEntry) {
|
|
11625
12635
|
const recent = summarizeDelegatedActivity(store.messagesFor(runningEntry[0]), running.startedAtMs ?? Date.now());
|
|
11626
12636
|
return json(res, 200, {
|
|
@@ -11665,7 +12675,7 @@ const handleRequest = async (req, res) => {
|
|
|
11665
12675
|
const target = store.bot(botId);
|
|
11666
12676
|
if (!target || target.id === from.id)
|
|
11667
12677
|
return json(res, 404, { error: "no such teammate" });
|
|
11668
|
-
if (target.hidden || !canAccessTeam(from, target.section) || !peerAllowed(from, target
|
|
12678
|
+
if (target.hidden || !canAccessTeam(from, target.section) || !peerAllowed(from, target)) {
|
|
11669
12679
|
return json(res, 403, { error: "that bot is not on this Chief's team — call list_bots for the ones you can reach" });
|
|
11670
12680
|
}
|
|
11671
12681
|
if (store.groupByThread(threadId))
|
|
@@ -11716,7 +12726,7 @@ const handleRequest = async (req, res) => {
|
|
|
11716
12726
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
11717
12727
|
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
11718
12728
|
}
|
|
11719
|
-
if (!peerAllowed(from, target
|
|
12729
|
+
if (!peerAllowed(from, target)) {
|
|
11720
12730
|
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
11721
12731
|
}
|
|
11722
12732
|
const fromThreadId = internalCapability.threadId;
|
|
@@ -11736,6 +12746,21 @@ const handleRequest = async (req, res) => {
|
|
|
11736
12746
|
return json(res, 200, { error: said[queued.result === "ok" ? "no_target" : queued.result] });
|
|
11737
12747
|
}
|
|
11738
12748
|
const targetName = store.bot(toBotId)?.name ?? toBotId;
|
|
12749
|
+
// The queue normally drains when the source thread's turn completes. A
|
|
12750
|
+
// caller this server did not spawn (an external runtime) has no live
|
|
12751
|
+
// turn here, so its handoff would wait for an unrelated turn on that
|
|
12752
|
+
// thread to settle. Nothing in flight on the source thread means there
|
|
12753
|
+
// is nothing to wait for: drain now.
|
|
12754
|
+
if (internalCapability.generation === EXTERNAL_RUNTIME_GENERATION && !threadBusy(from.id, fromThreadId)) {
|
|
12755
|
+
drainThreadDelegations(fromThreadId);
|
|
12756
|
+
return json(res, 200, {
|
|
12757
|
+
queued: true,
|
|
12758
|
+
taskId: queued.id,
|
|
12759
|
+
message: peerReviewRequired(from, fromThreadId)
|
|
12760
|
+
? `Queued for review — @${targetName} will pick it up once the user approves.`
|
|
12761
|
+
: `Delegated — @${targetName} is picking it up now.`,
|
|
12762
|
+
});
|
|
12763
|
+
}
|
|
11739
12764
|
return json(res, 200, {
|
|
11740
12765
|
queued: true,
|
|
11741
12766
|
taskId: queued.id,
|
|
@@ -11844,6 +12869,11 @@ const handleRequest = async (req, res) => {
|
|
|
11844
12869
|
target.threadId = resolved.task.threadId;
|
|
11845
12870
|
if (resolved.created)
|
|
11846
12871
|
createdThread = resolved.task.threadId;
|
|
12872
|
+
// A work thread carries one assignment: it is for the person
|
|
12873
|
+
// this coordination serves. The durable pair conversation is
|
|
12874
|
+
// shared by every assignment between two bots, so it names nobody.
|
|
12875
|
+
if (resolved.created && resolved.task.openedBy?.kind === "work")
|
|
12876
|
+
threadStarters.set(resolved.task.threadId, threadPersonKey(address.threadId));
|
|
11847
12877
|
if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
|
|
11848
12878
|
grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
|
|
11849
12879
|
}
|
|
@@ -12074,6 +13104,7 @@ const handleRequest = async (req, res) => {
|
|
|
12074
13104
|
const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
|
|
12075
13105
|
if (!task)
|
|
12076
13106
|
return json(res, 500, { error: "couldn't create that thread" });
|
|
13107
|
+
threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
|
|
12077
13108
|
internalCapability.openedThreads += 1;
|
|
12078
13109
|
const chip = {
|
|
12079
13110
|
role: "bot",
|
|
@@ -12109,12 +13140,14 @@ const handleRequest = async (req, res) => {
|
|
|
12109
13140
|
if (!canAccessTeam(from, target.section) || target.hidden) {
|
|
12110
13141
|
return json(res, 403, { error: `that bot belongs to a different section or is unavailable. ${PEER_ACCESS_HELP}` });
|
|
12111
13142
|
}
|
|
12112
|
-
if (!peerAllowed(from, target
|
|
13143
|
+
if (!peerAllowed(from, target)) {
|
|
12113
13144
|
return json(res, 403, { error: `that bot is not on this bot's allowed peers. ${PEER_ACCESS_HELP}` });
|
|
12114
13145
|
}
|
|
12115
13146
|
const task = store.createTask(target.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
|
|
12116
13147
|
if (!task)
|
|
12117
13148
|
return json(res, 500, { error: "couldn't create that thread" });
|
|
13149
|
+
// The work is still for the person whose request the opener is on.
|
|
13150
|
+
threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
|
|
12118
13151
|
if (delegatedFullAccess(from, fromThreadId, target))
|
|
12119
13152
|
grantDelegatedFullAccess(from, target, task.threadId);
|
|
12120
13153
|
const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
|
|
@@ -12198,6 +13231,8 @@ const handleRequest = async (req, res) => {
|
|
|
12198
13231
|
description: instructions,
|
|
12199
13232
|
modelSelection: { ...chief.modelSelection },
|
|
12200
13233
|
section: chief.section,
|
|
13234
|
+
// exactly the Chief's audience: a restricted Chief never makes a bot everyone sees
|
|
13235
|
+
...(chief.visibility ? { visibility: chief.visibility } : {}),
|
|
12201
13236
|
}, { seedMessages: false });
|
|
12202
13237
|
const safeBot = store.patchBot(created.id, {
|
|
12203
13238
|
composio: false,
|
|
@@ -12515,11 +13550,11 @@ const handleRequest = async (req, res) => {
|
|
|
12515
13550
|
// transcripts: this projection carries only ids, status relationships,
|
|
12516
13551
|
// optional delegation labels, and timestamps.
|
|
12517
13552
|
if (method === "GET" && path === "/api/team-map") {
|
|
12518
|
-
const
|
|
13553
|
+
const mapped = new Set(store.bots.filter((bot) => !bot.hidden && visible.bot(bot.id)).map((bot) => bot.id));
|
|
12519
13554
|
const collaborations = store.groups
|
|
12520
13555
|
.filter((group) => group.dm === true &&
|
|
12521
13556
|
group.memberIds.length === 2 &&
|
|
12522
|
-
group.memberIds.every((botId) =>
|
|
13557
|
+
group.memberIds.every((botId) => mapped.has(botId)))
|
|
12523
13558
|
.map((group) => ({
|
|
12524
13559
|
groupId: group.id,
|
|
12525
13560
|
botIds: [group.memberIds[0], group.memberIds[1]],
|
|
@@ -12527,17 +13562,17 @@ const handleRequest = async (req, res) => {
|
|
|
12527
13562
|
}))
|
|
12528
13563
|
.sort((a, b) => b.lastAt - a.lastAt);
|
|
12529
13564
|
const queued = pendingDelegationSnapshot().flatMap((item) => {
|
|
12530
|
-
if (!
|
|
13565
|
+
if (!mapped.has(item.sourceBotId) || !mapped.has(item.toBotId))
|
|
12531
13566
|
return [];
|
|
12532
13567
|
return [{ sourceBotId: item.sourceBotId, targetBotId: item.toBotId, reason: item.reason }];
|
|
12533
13568
|
});
|
|
12534
13569
|
const running = [...delegationWatch.entries()].flatMap(([threadId, watch]) => {
|
|
12535
|
-
if (!
|
|
13570
|
+
if (!mapped.has(watch.toBotId))
|
|
12536
13571
|
return [];
|
|
12537
13572
|
const channel = watch.channelId ? store.group(watch.channelId) : undefined;
|
|
12538
13573
|
const sourceBotId = watch.sourceBotId ??
|
|
12539
13574
|
channel?.memberIds.find((botId) => botId !== watch.toBotId);
|
|
12540
|
-
if (!sourceBotId || !
|
|
13575
|
+
if (!sourceBotId || !mapped.has(sourceBotId))
|
|
12541
13576
|
return [];
|
|
12542
13577
|
return [{ sourceBotId, targetBotId: watch.toBotId, threadId, groupId: channel?.id }];
|
|
12543
13578
|
});
|
|
@@ -12550,12 +13585,17 @@ const handleRequest = async (req, res) => {
|
|
|
12550
13585
|
const from = fromParam == null ? undefined : Number(fromParam);
|
|
12551
13586
|
const to = toParam == null ? undefined : Number(toParam);
|
|
12552
13587
|
return json(res, 200, {
|
|
12553
|
-
routines: routines.listRoutines(),
|
|
12554
|
-
runs: routines.listRuns(from != null && Number.isFinite(from) ? from : undefined, to != null && Number.isFinite(to) ? to : undefined)
|
|
13588
|
+
routines: routines.listRoutines().filter((routine) => routineVisible(routine, visible)),
|
|
13589
|
+
runs: routines.listRuns(from != null && Number.isFinite(from) ? from : undefined, to != null && Number.isFinite(to) ? to : undefined)
|
|
13590
|
+
.filter((run) => routineVisible(run, visible)),
|
|
12555
13591
|
});
|
|
12556
13592
|
}
|
|
12557
13593
|
if (path === "/api/routines" && method === "POST") {
|
|
12558
|
-
|
|
13594
|
+
const body = await readBody(req);
|
|
13595
|
+
const hidden = hiddenRoutineTarget(body, visible);
|
|
13596
|
+
if (hidden)
|
|
13597
|
+
return json(res, 404, { error: hidden });
|
|
13598
|
+
return json(res, 201, { routine: routines.create(body) });
|
|
12559
13599
|
}
|
|
12560
13600
|
// The desktop shell polls this to decide whether to hold the computer
|
|
12561
13601
|
// awake: a run in flight, or a routine due within the hour.
|
|
@@ -12569,7 +13609,11 @@ const handleRequest = async (req, res) => {
|
|
|
12569
13609
|
}
|
|
12570
13610
|
routineMatch = path.match(/^\/api\/routines\/([\w-]+)$/);
|
|
12571
13611
|
if (routineMatch && method === "PATCH") {
|
|
12572
|
-
const
|
|
13612
|
+
const body = await readBody(req);
|
|
13613
|
+
const hidden = hiddenRoutineTarget(body, visible);
|
|
13614
|
+
if (hidden)
|
|
13615
|
+
return json(res, 404, { error: hidden });
|
|
13616
|
+
const routine = routines.update(routineMatch[1], body);
|
|
12573
13617
|
return routine ? json(res, 200, { routine }) : json(res, 404, { error: "no such routine" });
|
|
12574
13618
|
}
|
|
12575
13619
|
if (routineMatch && method === "DELETE") {
|
|
@@ -12577,6 +13621,9 @@ const handleRequest = async (req, res) => {
|
|
|
12577
13621
|
? json(res, 200, { ok: true })
|
|
12578
13622
|
: json(res, 404, { error: "no such routine" });
|
|
12579
13623
|
}
|
|
13624
|
+
if (path === "/api/routine-runs/seen-all" && method === "POST") {
|
|
13625
|
+
return json(res, 200, { runs: routines.markAllSeen() });
|
|
13626
|
+
}
|
|
12580
13627
|
const runMatch = path.match(/^\/api\/routine-runs\/([\w-]+)\/(cancel|seen)$/);
|
|
12581
13628
|
if (runMatch && method === "POST") {
|
|
12582
13629
|
const run = runMatch[2] === "cancel"
|
|
@@ -12590,7 +13637,11 @@ const handleRequest = async (req, res) => {
|
|
|
12590
13637
|
}
|
|
12591
13638
|
if (path === "/api/calendar-calls" && method === "POST") {
|
|
12592
13639
|
try {
|
|
12593
|
-
|
|
13640
|
+
const body = await readBody(req);
|
|
13641
|
+
const conflict = calendarAudienceConflict(body);
|
|
13642
|
+
if (conflict)
|
|
13643
|
+
return json(res, 400, { error: conflict });
|
|
13644
|
+
return json(res, 201, { call: calendarCalls.create(body) });
|
|
12594
13645
|
}
|
|
12595
13646
|
catch (error) {
|
|
12596
13647
|
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 });
|
|
@@ -12612,7 +13663,11 @@ const handleRequest = async (req, res) => {
|
|
|
12612
13663
|
if (!calendarCalls.get(calendarCallMatch[1]))
|
|
12613
13664
|
return json(res, 404, { error: "no such scheduled call" });
|
|
12614
13665
|
try {
|
|
12615
|
-
|
|
13666
|
+
const body = await readBody(req);
|
|
13667
|
+
const conflict = calendarAudienceConflict(body);
|
|
13668
|
+
if (conflict)
|
|
13669
|
+
return json(res, 400, { error: conflict });
|
|
13670
|
+
return json(res, 200, { call: calendarCalls.update(calendarCallMatch[1], body) });
|
|
12616
13671
|
}
|
|
12617
13672
|
catch (error) {
|
|
12618
13673
|
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { status: 400 });
|
|
@@ -12628,7 +13683,9 @@ const handleRequest = async (req, res) => {
|
|
|
12628
13683
|
// second, webhook-only loopback listener so Funnel or a future hosted
|
|
12629
13684
|
// relay never has to expose the rest of OpenMausBot's control surface.
|
|
12630
13685
|
if (path === "/api/webhooks" && method === "GET") {
|
|
12631
|
-
|
|
13686
|
+
const shownHooks = webhooks.list().filter((webhook) => visible.bot(webhook.botId));
|
|
13687
|
+
const shownIds = new Set(shownHooks.map((webhook) => webhook.id));
|
|
13688
|
+
return json(res, 200, { webhooks: shownHooks, attempts: webhooks.listAttempts().filter((attempt) => visible.everything || shownIds.has(attempt.webhookId)), ingress: webhookIngressStatus() });
|
|
12632
13689
|
}
|
|
12633
13690
|
if (path === "/api/webhooks" && method === "POST") {
|
|
12634
13691
|
const created = webhooks.create(await readBody(req));
|
|
@@ -12716,11 +13773,19 @@ const handleRequest = async (req, res) => {
|
|
|
12716
13773
|
return json(res, 405, { error: "method not allowed" });
|
|
12717
13774
|
}
|
|
12718
13775
|
if (method === "GET" && path === "/api/events") {
|
|
13776
|
+
const viewer = viewerFor(auth);
|
|
13777
|
+
const visibleNow = visibleTo(viewer);
|
|
12719
13778
|
const client = {
|
|
12720
13779
|
res,
|
|
12721
13780
|
admin: auth.scopes.includes("admin"),
|
|
12722
13781
|
screens: url.searchParams.get("screens") !== "off",
|
|
12723
13782
|
backpressured: false,
|
|
13783
|
+
viewer,
|
|
13784
|
+
// What this person's hydration (GET /api/bots, narrowed the same way) showed them.
|
|
13785
|
+
seen: viewer.kind === "all" ? { bots: new Set(), groups: new Set() } : {
|
|
13786
|
+
bots: new Set(store.bots.filter((bot) => visibleNow.bot(bot.id)).map((bot) => bot.id)),
|
|
13787
|
+
groups: new Set(store.groups.filter((group) => visibleNow.group(group.id)).map((group) => group.id)),
|
|
13788
|
+
},
|
|
12724
13789
|
};
|
|
12725
13790
|
if (auth.kind === "session")
|
|
12726
13791
|
client.sessionId = auth.session.id;
|
|
@@ -12746,7 +13811,11 @@ const handleRequest = async (req, res) => {
|
|
|
12746
13811
|
// would leave a permanent hole in its state.
|
|
12747
13812
|
const resumed = since !== null &&
|
|
12748
13813
|
since <= lastSeq &&
|
|
12749
|
-
(replayBuffer.length === 0 ? since === lastSeq : replayBuffer[0].seq <= since + 1)
|
|
13814
|
+
(replayBuffer.length === 0 ? since === lastSeq : replayBuffer[0].seq <= since + 1) &&
|
|
13815
|
+
// A member whose cursor predates a bot's audience change gets a fresh
|
|
13816
|
+
// snapshot: a replay is judged by today's audience and could neither
|
|
13817
|
+
// withdraw what the change hid nor bring back whole what it showed.
|
|
13818
|
+
!(viewer.kind === "member" && audienceChangedAt > 0 && since <= audienceChangedAt);
|
|
12750
13819
|
res.write(`data: ${JSON.stringify({
|
|
12751
13820
|
kind: "hello",
|
|
12752
13821
|
cursor: `${STREAM_ID}:${lastSeq}`,
|
|
@@ -12757,8 +13826,14 @@ const handleRequest = async (req, res) => {
|
|
|
12757
13826
|
})}\n\n`);
|
|
12758
13827
|
if (resumed) {
|
|
12759
13828
|
for (const buffered of replayBuffer) {
|
|
12760
|
-
|
|
12761
|
-
|
|
13829
|
+
if (buffered.seq <= since || !wants(client, buffered.kind))
|
|
13830
|
+
continue;
|
|
13831
|
+
const frame = client.admin
|
|
13832
|
+
? buffered.frame
|
|
13833
|
+
: buffered.payload
|
|
13834
|
+
? memberFrame(client, buffered.seq, buffered.payload, buffered.clientFrame)
|
|
13835
|
+
: buffered.clientFrame;
|
|
13836
|
+
if (frame)
|
|
12762
13837
|
res.write(frame);
|
|
12763
13838
|
}
|
|
12764
13839
|
}
|
|
@@ -12807,16 +13882,23 @@ const handleRequest = async (req, res) => {
|
|
|
12807
13882
|
// tasks stays explicit, because that is the one field publicBot() adds
|
|
12808
13883
|
// that messagePage() does not: wireBot() omits the key entirely for a
|
|
12809
13884
|
// bot record carrying no tasks, where publicBot() always sent [].
|
|
13885
|
+
// A member on a workspace with a restricted bot gets only what they may
|
|
13886
|
+
// see (bot-visibility.ts); for everyone else these filters keep all.
|
|
13887
|
+
const shownBots = store.bots.filter((bot) => visible.bot(bot.id));
|
|
13888
|
+
const queued = publicBotQueuedMessages();
|
|
12810
13889
|
return json(res, 200, {
|
|
12811
|
-
bots:
|
|
13890
|
+
bots: shownBots.map((bot) => memberBot({
|
|
12812
13891
|
...wireBot(bot),
|
|
12813
13892
|
tasks: store.tasks(bot.id).map(wireTask),
|
|
12814
13893
|
...messagePage(bot.threadId, limit),
|
|
12815
|
-
})),
|
|
12816
|
-
botQueuedMessages:
|
|
12817
|
-
sections: store.sections,
|
|
12818
|
-
groups: store.groups.
|
|
12819
|
-
|
|
13894
|
+
}, visible)),
|
|
13895
|
+
botQueuedMessages: visible.everything ? queued : Object.fromEntries(Object.entries(queued).filter(([threadId]) => visible.thread(threadId))),
|
|
13896
|
+
sections: visible.sections(store.sections),
|
|
13897
|
+
groups: store.groups.filter((g) => visible.group(g.id)).map((g) => {
|
|
13898
|
+
const room = { ...publicGroupState(g), ...messagePage(g.threadId, limit) };
|
|
13899
|
+
return visible.everything ? room : memberGroup(room);
|
|
13900
|
+
}),
|
|
13901
|
+
computerControl: Object.fromEntries(shownBots.map((bot) => {
|
|
12820
13902
|
const snapshot = botComputerControlSnapshot(bot.id);
|
|
12821
13903
|
return [bot.id, { held: snapshot.held, helpReason: snapshot.helpReason }];
|
|
12822
13904
|
})),
|
|
@@ -13124,7 +14206,7 @@ const handleRequest = async (req, res) => {
|
|
|
13124
14206
|
const rawLimit = url.searchParams.get("limit");
|
|
13125
14207
|
const limit = rawLimit ? Math.min(Math.max(Number(rawLimit) || 0, 1), 100) : 40;
|
|
13126
14208
|
const threadId = url.searchParams.get("threadId")?.trim() || undefined;
|
|
13127
|
-
if (threadId && !store.botByThread(threadId) && !store.groupByThread(threadId)) {
|
|
14209
|
+
if (threadId && ((!store.botByThread(threadId) && !store.groupByThread(threadId)) || !visible.thread(threadId))) {
|
|
13128
14210
|
return json(res, 404, { error: "no such conversation" });
|
|
13129
14211
|
}
|
|
13130
14212
|
// whether each hit sits on its thread's visible branch — a click on
|
|
@@ -13136,7 +14218,11 @@ const handleRequest = async (req, res) => {
|
|
|
13136
14218
|
activePaths.set(threadId, (ids = new Set(store.activePath(threadId).map((m) => m.id))));
|
|
13137
14219
|
return ids.has(messageId);
|
|
13138
14220
|
};
|
|
13139
|
-
|
|
14221
|
+
// A member who may not see every bot: look further, then keep only
|
|
14222
|
+
// hits in conversations they can open.
|
|
14223
|
+
const hits = searchMessages(q, visible.everything ? limit : Math.min(limit * 10, 1_000), threadId)
|
|
14224
|
+
.filter((hit) => visible.thread(hit.threadId))
|
|
14225
|
+
.slice(0, limit)
|
|
13140
14226
|
.map((hit) => {
|
|
13141
14227
|
const bot = store.botByThread(hit.threadId);
|
|
13142
14228
|
const group = bot ? undefined : store.groupByThread(hit.threadId);
|
|
@@ -13208,7 +14294,13 @@ const handleRequest = async (req, res) => {
|
|
|
13208
14294
|
}
|
|
13209
14295
|
// ── channels (persisted internally as groups) ───────────────────────
|
|
13210
14296
|
if (method === "POST" && path === "/api/groups") {
|
|
13211
|
-
const
|
|
14297
|
+
const body = await readBody(req);
|
|
14298
|
+
// A member may only put bots they can see in a room: the room would
|
|
14299
|
+
// otherwise show them a restricted bot (bot-visibility.ts).
|
|
14300
|
+
const hiddenMember = Array.isArray(body?.memberIds) ? body.memberIds.find((id) => typeof id === "string" && store.bot(id) && !visible.bot(id)) : undefined;
|
|
14301
|
+
if (hiddenMember)
|
|
14302
|
+
return json(res, 400, { error: `unknown channel member: ${String(hiddenMember)}` });
|
|
14303
|
+
const group = createChannel(body);
|
|
13212
14304
|
return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
|
|
13213
14305
|
}
|
|
13214
14306
|
if (method === "POST" && path === "/api/teams/export") {
|
|
@@ -13350,12 +14442,31 @@ const handleRequest = async (req, res) => {
|
|
|
13350
14442
|
projectCwd = validated.cwd;
|
|
13351
14443
|
}
|
|
13352
14444
|
}
|
|
14445
|
+
// Who may see the imported bots, from their first moment: the admin's
|
|
14446
|
+
// choice (?visibility=admins, or a JSON value), never the file's.
|
|
14447
|
+
const rawVisibility = url.searchParams.get("visibility");
|
|
14448
|
+
let importVisibility;
|
|
14449
|
+
if (rawVisibility !== null) {
|
|
14450
|
+
let value = rawVisibility;
|
|
14451
|
+
if (rawVisibility.trim().startsWith("{")) {
|
|
14452
|
+
try {
|
|
14453
|
+
value = JSON.parse(rawVisibility);
|
|
14454
|
+
}
|
|
14455
|
+
catch {
|
|
14456
|
+
/* parseVisibility refuses the raw text with a plain sentence */
|
|
14457
|
+
}
|
|
14458
|
+
}
|
|
14459
|
+
const parsed = parseVisibility(value);
|
|
14460
|
+
if (!parsed.ok)
|
|
14461
|
+
return json(res, 400, { error: parsed.error });
|
|
14462
|
+
importVisibility = parsed.visibility;
|
|
14463
|
+
}
|
|
13353
14464
|
const body = await readBody(req, MAX_TEAM_BACKUP_BYTES);
|
|
13354
14465
|
if (body?.format === "openmaus.backup") {
|
|
13355
14466
|
if (importMode !== "add")
|
|
13356
14467
|
return json(res, 400, { error: "Import backups alongside your existing bots; project mode is only for templates" });
|
|
13357
14468
|
try {
|
|
13358
|
-
const imported = importTeamBackup(store, routines, body, await defaultSelection());
|
|
14469
|
+
const imported = importTeamBackup(store, routines, body, await defaultSelection(), { visibility: importVisibility });
|
|
13359
14470
|
const bots = imported.bots.map((bot) => publicBot(bot));
|
|
13360
14471
|
const groups = imported.groups.map((group) => ({ ...publicGroupState(group), ...messagePage(group.threadId, undefined) }));
|
|
13361
14472
|
for (const bot of bots)
|
|
@@ -13418,6 +14529,7 @@ const handleRequest = async (req, res) => {
|
|
|
13418
14529
|
...importedMemberProfile(member, takenNames),
|
|
13419
14530
|
modelSelection: selection,
|
|
13420
14531
|
section: importSection,
|
|
14532
|
+
...(importVisibility ? { visibility: importVisibility } : {}),
|
|
13421
14533
|
}, { seedMessages: false });
|
|
13422
14534
|
importedBots.push(created);
|
|
13423
14535
|
const installedPlaybooks = source.playbookKeys.flatMap((key) => {
|
|
@@ -13614,6 +14726,9 @@ const handleRequest = async (req, res) => {
|
|
|
13614
14726
|
const task = store.createGroupTask(group.id, request.data.title);
|
|
13615
14727
|
if (!task)
|
|
13616
14728
|
return json(res, 500, { error: "couldn't create that task" });
|
|
14729
|
+
// Who opened it decides who may answer its cards on a shared workspace.
|
|
14730
|
+
if (auth.kind === "session")
|
|
14731
|
+
threadStarters.set(task.threadId, personKey(auth.session));
|
|
13617
14732
|
const fresh = groupWithThread(store.group(group.id));
|
|
13618
14733
|
broadcast({ kind: "group", group: fresh });
|
|
13619
14734
|
return json(res, 201, { group: fresh, task });
|
|
@@ -13669,15 +14784,43 @@ const handleRequest = async (req, res) => {
|
|
|
13669
14784
|
return json(res, 404, { error: "no such channel" });
|
|
13670
14785
|
if (group.dm)
|
|
13671
14786
|
return json(res, 400, { error: "bot-to-bot channels keep one canonical conversation" });
|
|
13672
|
-
if (channelTaskBlocked(group)) {
|
|
13673
|
-
return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
|
|
13674
|
-
}
|
|
13675
14787
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
13676
14788
|
return json(res, 400, { error: "body must be a JSON object" });
|
|
13677
14789
|
}
|
|
13678
|
-
const
|
|
13679
|
-
if (!
|
|
14790
|
+
const allowed = new Set(["title", "pinned"]);
|
|
14791
|
+
if (Object.keys(body).some((key) => !allowed.has(key)))
|
|
14792
|
+
return json(res, 400, { error: "unsupported channel thread setting" });
|
|
14793
|
+
const existing = store.groupTaskByThread(group.id, m[2]);
|
|
14794
|
+
if (!existing)
|
|
13680
14795
|
return json(res, 404, { error: "no such channel task" });
|
|
14796
|
+
const pinning = body.pinned !== undefined;
|
|
14797
|
+
const renaming = body.title !== undefined;
|
|
14798
|
+
if (!pinning && !renaming)
|
|
14799
|
+
return json(res, 400, { error: "unsupported channel thread setting" });
|
|
14800
|
+
if (pinning && typeof body.pinned !== "boolean")
|
|
14801
|
+
return json(res, 400, { error: "pinned must be a boolean" });
|
|
14802
|
+
if (renaming && typeof body.title !== "string")
|
|
14803
|
+
return json(res, 400, { error: "title must be a string" });
|
|
14804
|
+
// Echoing the current title lets a newer client pin on an older server
|
|
14805
|
+
// without the old handler turning a missing title into "Untitled".
|
|
14806
|
+
// That echo is not a rename. A real rename stays blocked while working.
|
|
14807
|
+
const titleChange = renaming && body.title !== existing.title;
|
|
14808
|
+
if (channelTaskBlocked(group) && !(pinning && !titleChange)) {
|
|
14809
|
+
return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
|
|
14810
|
+
}
|
|
14811
|
+
let task = existing;
|
|
14812
|
+
if (pinning) {
|
|
14813
|
+
const pinned = store.setGroupTaskPinned(group.id, m[2], body.pinned === true);
|
|
14814
|
+
if (!pinned)
|
|
14815
|
+
return json(res, 404, { error: "no such channel task" });
|
|
14816
|
+
task = pinned;
|
|
14817
|
+
}
|
|
14818
|
+
if (titleChange) {
|
|
14819
|
+
const renamed = store.renameGroupTask(group.id, m[2], body.title);
|
|
14820
|
+
if (!renamed)
|
|
14821
|
+
return json(res, 404, { error: "no such channel task" });
|
|
14822
|
+
task = renamed;
|
|
14823
|
+
}
|
|
13681
14824
|
return json(res, 200, { task });
|
|
13682
14825
|
}
|
|
13683
14826
|
if (m && method === "DELETE") {
|
|
@@ -13772,7 +14915,7 @@ const handleRequest = async (req, res) => {
|
|
|
13772
14915
|
return json(res, 400, { error: "threadId must be a task id" });
|
|
13773
14916
|
}
|
|
13774
14917
|
const threadId = body.threadId ?? group.threadId;
|
|
13775
|
-
|
|
14918
|
+
const trigger = usageTriggerFor(auth);
|
|
13776
14919
|
try {
|
|
13777
14920
|
assertWithinBudget(cfg, DATA_DIR);
|
|
13778
14921
|
}
|
|
@@ -13836,10 +14979,12 @@ const handleRequest = async (req, res) => {
|
|
|
13836
14979
|
sendId,
|
|
13837
14980
|
mode: channelMode,
|
|
13838
14981
|
via,
|
|
14982
|
+
sender: messageSender(auth),
|
|
14983
|
+
trigger,
|
|
13839
14984
|
});
|
|
13840
14985
|
return { ok: true, queued: true, queueId: queued.id, threadId };
|
|
13841
14986
|
}
|
|
13842
|
-
const message = startGroupTurn(current.id, text, replyTo, sendId, channelMode, undefined, { via, sender: messageSender(auth) });
|
|
14987
|
+
const message = startGroupTurn(current.id, text, replyTo, sendId, channelMode, undefined, { via, sender: messageSender(auth), trigger });
|
|
13843
14988
|
return { ok: true, threadId, message };
|
|
13844
14989
|
});
|
|
13845
14990
|
return json(res, 202, receipt);
|
|
@@ -13879,7 +15024,8 @@ const handleRequest = async (req, res) => {
|
|
|
13879
15024
|
if (!ownsThread) {
|
|
13880
15025
|
return json(res, 409, { error: "the channel switched tasks before it could receive the message" });
|
|
13881
15026
|
}
|
|
13882
|
-
|
|
15027
|
+
// Steering folds words into the running room turn; who pressed Steer
|
|
15028
|
+
// does not re-book that turn (its sender was captured when it started).
|
|
13883
15029
|
const current = store.group(group.id);
|
|
13884
15030
|
if (!current)
|
|
13885
15031
|
return json(res, 404, { error: "no such room" });
|
|
@@ -13937,6 +15083,7 @@ const handleRequest = async (req, res) => {
|
|
|
13937
15083
|
queueId: head.id,
|
|
13938
15084
|
via: head.via,
|
|
13939
15085
|
steered: true,
|
|
15086
|
+
sender: head.sender,
|
|
13940
15087
|
});
|
|
13941
15088
|
settleHeldChannelQueueHead(held);
|
|
13942
15089
|
return json(res, 200, {
|
|
@@ -14118,12 +15265,20 @@ const handleRequest = async (req, res) => {
|
|
|
14118
15265
|
return json(res, checked.status, { error: checked.error });
|
|
14119
15266
|
selection = checked.selection;
|
|
14120
15267
|
}
|
|
15268
|
+
if (hostedModels && !hostedModels.allows(selection))
|
|
15269
|
+
return json(res, 400, { error: hostedModels.error() });
|
|
14121
15270
|
// Keep the capacity check immediately beside the synchronous write.
|
|
14122
15271
|
// Awaiting provider discovery before this point cannot race the cap.
|
|
14123
15272
|
if (store.bots.length >= MAX_WORKSPACE_BOTS) {
|
|
14124
15273
|
return json(res, 409, { error: `this workspace is limited to ${MAX_WORKSPACE_BOTS} bots` });
|
|
14125
15274
|
}
|
|
14126
|
-
|
|
15275
|
+
// Who may see it, from the first moment (admin route: members cannot
|
|
15276
|
+
// create bots). A bot named for a sensitive job must never be
|
|
15277
|
+
// announced to everyone before it is restricted.
|
|
15278
|
+
const created = body.visibility === undefined ? null : parseVisibility(body.visibility);
|
|
15279
|
+
if (created && !created.ok)
|
|
15280
|
+
return json(res, 400, { error: created.error });
|
|
15281
|
+
const bot = store.createBot({ ...profile.patch, section, modelSelection: selection, ...(created?.ok ? { visibility: created.visibility } : {}) });
|
|
14127
15282
|
return json(res, 201, {
|
|
14128
15283
|
bot: {
|
|
14129
15284
|
...wireBot(bot),
|
|
@@ -14228,7 +15383,8 @@ const handleRequest = async (req, res) => {
|
|
|
14228
15383
|
const bot = store.patchBot(existing.id, { modelSelection: checked.selection });
|
|
14229
15384
|
if (!bot)
|
|
14230
15385
|
return json(res, 404, { error: "no such bot" });
|
|
14231
|
-
store.patchTask(bot.id, selected.threadId, { modelSelection: checked.selection
|
|
15386
|
+
store.patchTask(bot.id, selected.threadId, { modelSelection: checked.selection,
|
|
15387
|
+
...hostedModels?.resetTask(selected.modelSelection, checked.selection) });
|
|
14232
15388
|
return json(res, 200, { bot: wireBot(bot) });
|
|
14233
15389
|
}
|
|
14234
15390
|
m = path.match(/^\/api\/bots\/([\w-]+)\/read$/);
|
|
@@ -14250,13 +15406,18 @@ const handleRequest = async (req, res) => {
|
|
|
14250
15406
|
const bot = requestedTaskBot(m[1], body.threadId);
|
|
14251
15407
|
if (!allowKey)
|
|
14252
15408
|
return json(res, 400, { error: "allowKey required" });
|
|
14253
|
-
const pending = store.messagesFor(bot.threadId).
|
|
15409
|
+
const pending = store.messagesFor(bot.threadId).find((message) => message.card?.requestId &&
|
|
14254
15410
|
!message.card.answered &&
|
|
14255
15411
|
message.card.dismissed !== true &&
|
|
14256
15412
|
message.card.allowKey === allowKey);
|
|
14257
15413
|
if (!pending) {
|
|
14258
15414
|
return json(res, 409, { error: "that grant is not on a pending approval for this bot" });
|
|
14259
15415
|
}
|
|
15416
|
+
// A standing grant is an approval: the same people who may approve
|
|
15417
|
+
// the card it came from may remember it.
|
|
15418
|
+
const refusal = cardAnswerRefusal(auth, bot.threadId, pending.card.requestId, "allow");
|
|
15419
|
+
if (refusal)
|
|
15420
|
+
return json(res, 403, { error: refusal });
|
|
14260
15421
|
store.patchTask(bot.id, bot.threadId, {
|
|
14261
15422
|
alwaysAllow: [...new Set([...(bot.alwaysAllow ?? []), allowKey])].slice(0, 200),
|
|
14262
15423
|
});
|
|
@@ -14282,6 +15443,7 @@ const handleRequest = async (req, res) => {
|
|
|
14282
15443
|
const existingBot = store.bot(m[1]);
|
|
14283
15444
|
const selectedTask = existingBot ? requestedTaskBot(existingBot.id, undefined) : null;
|
|
14284
15445
|
const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
|
|
15446
|
+
const beforeVisibility = existingBot?.visibility;
|
|
14285
15447
|
if (body.requireAvailableModel !== undefined && typeof body.requireAvailableModel !== "boolean") {
|
|
14286
15448
|
return json(res, 400, { error: "requireAvailableModel must be true or false" });
|
|
14287
15449
|
}
|
|
@@ -14616,6 +15778,16 @@ const handleRequest = async (req, res) => {
|
|
|
14616
15778
|
// authority to return if this bot is elected Chief again later.
|
|
14617
15779
|
if (body.chiefOfStaff === false)
|
|
14618
15780
|
patch.managedSections = [];
|
|
15781
|
+
// Who may see this bot on a workspace several people share
|
|
15782
|
+
// (bot-visibility.ts): "everyone" (the default), "admins", or
|
|
15783
|
+
// { people: [...] }. Access control, applied at once; members' PATCHes
|
|
15784
|
+
// never get here (clientBotPatchViolation).
|
|
15785
|
+
if (Object.hasOwn(body, "visibility")) {
|
|
15786
|
+
const parsed = parseVisibility(body.visibility);
|
|
15787
|
+
if (!parsed.ok)
|
|
15788
|
+
return json(res, 400, { error: parsed.error });
|
|
15789
|
+
patch.visibility = parsed.visibility;
|
|
15790
|
+
}
|
|
14619
15791
|
if (body.alwaysAllow !== undefined) {
|
|
14620
15792
|
if (!Array.isArray(body.alwaysAllow) || body.alwaysAllow.some((t) => typeof t !== "string")) {
|
|
14621
15793
|
return json(res, 400, { error: "alwaysAllow must be a list of tool keys" });
|
|
@@ -14749,7 +15921,8 @@ const handleRequest = async (req, res) => {
|
|
|
14749
15921
|
if (!bot)
|
|
14750
15922
|
return json(res, 404, { error: "no such bot" });
|
|
14751
15923
|
if (normalizedSelection && selectedTask)
|
|
14752
|
-
store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection
|
|
15924
|
+
store.patchTask(bot.id, selectedTask.threadId, { modelSelection: normalizedSelection,
|
|
15925
|
+
...hostedModels?.resetTask(selectedTask.modelSelection, normalizedSelection) });
|
|
14753
15926
|
if (existingBot && (bot.browserProfile !== beforeBrowserProfile || bot.browser !== beforeBrowserEnabled)) {
|
|
14754
15927
|
browserLive.closeForBot(bot.id);
|
|
14755
15928
|
if (beforeBrowserProfile === "guest" && (bot.browserProfile !== "guest" || bot.browser === false)) {
|
|
@@ -14765,6 +15938,15 @@ const handleRequest = async (req, res) => {
|
|
|
14765
15938
|
const now = store.bot(bot.id);
|
|
14766
15939
|
recordProfileChange(bot.id, "user", "api", beforeProfile, profileSnapshot(now));
|
|
14767
15940
|
}
|
|
15941
|
+
// A new audience changes who may see this bot's rooms and teams too:
|
|
15942
|
+
// re-announce them so every member's stream gains or withdraws them.
|
|
15943
|
+
if (Object.hasOwn(patch, "visibility") && JSON.stringify(beforeVisibility ?? "everyone") !== JSON.stringify(bot.visibility ?? "everyone")) {
|
|
15944
|
+
for (const group of store.groups.filter((candidate) => candidate.memberIds.includes(bot.id))) {
|
|
15945
|
+
broadcast({ kind: "group", group: publicGroupState(group) });
|
|
15946
|
+
}
|
|
15947
|
+
broadcast({ kind: "sections", sections: store.sections });
|
|
15948
|
+
audienceChanged();
|
|
15949
|
+
}
|
|
14768
15950
|
return json(res, 200, { bot: wireBot(store.bot(bot.id)) });
|
|
14769
15951
|
}
|
|
14770
15952
|
if (method === "POST" && path === "/api/local-computer/interrupt") {
|
|
@@ -15261,6 +16443,35 @@ const handleRequest = async (req, res) => {
|
|
|
15261
16443
|
});
|
|
15262
16444
|
return json(res, 200, { message: patched });
|
|
15263
16445
|
}
|
|
16446
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/requests\/([A-Za-z0-9_-]{16,80})(\/interrupt)?$/);
|
|
16447
|
+
if (m && ((method === "GET" && !m[3]) || (method === "POST" && m[3]))) {
|
|
16448
|
+
const body = method === "GET" ? { threadId: url.searchParams.get("threadId") } : await readBody(req);
|
|
16449
|
+
const id = z.string().regex(/^[\w-]+$/);
|
|
16450
|
+
const parsed = (method === "GET" ? z.object({ threadId: id }).strict() : z.object({
|
|
16451
|
+
threadId: id, messageId: id, expectedActiveLeafId: id.nullable(),
|
|
16452
|
+
expectedTurnId: z.string().min(1).max(200).nullable(), expectedExecutionId: id.nullable(),
|
|
16453
|
+
}).strict()).safeParse(body);
|
|
16454
|
+
if (!parsed.success)
|
|
16455
|
+
return json(res, 400, { error: "A pinned thread and exact request snapshot are required" });
|
|
16456
|
+
const threadId = parsed.data.threadId;
|
|
16457
|
+
const snapshot = guardedRequestSnapshot(m[1], threadId, m[2]);
|
|
16458
|
+
res.setHeader("cache-control", "private, no-store");
|
|
16459
|
+
if (method === "GET")
|
|
16460
|
+
return json(res, 200, snapshot);
|
|
16461
|
+
assertRequestTarget(snapshot, body);
|
|
16462
|
+
const owner = directRequestOwners.get(threadId);
|
|
16463
|
+
if (snapshot.phase === "untracked" || !owner || owner.messageId !== snapshot.messageId) {
|
|
16464
|
+
return json(res, 409, { error: "This continuation cannot be safely stopped from this interface", code: "guarded_request_untracked" });
|
|
16465
|
+
}
|
|
16466
|
+
if (snapshot.phase === "settled")
|
|
16467
|
+
throw requestConflict("This request has already settled");
|
|
16468
|
+
// No await between comparing the exact lease and revoking it. Retrying
|
|
16469
|
+
// this body cannot bind to a later setup claim with a null provider id.
|
|
16470
|
+
owner.stopped = true;
|
|
16471
|
+
handoffs.stoppedByPerson(threadId);
|
|
16472
|
+
await interruptDirectThread(m[1], threadId);
|
|
16473
|
+
return json(res, 200, { ok: true, outcome: "stopped" });
|
|
16474
|
+
}
|
|
15264
16475
|
m = path.match(/^\/api\/bots\/([\w-]+)\/messages(?:\/(guarded))?$/);
|
|
15265
16476
|
if (m && method === "POST") {
|
|
15266
16477
|
const guarded = m[2] === "guarded";
|
|
@@ -15270,14 +16481,12 @@ const handleRequest = async (req, res) => {
|
|
|
15270
16481
|
}
|
|
15271
16482
|
// External interfaces must opt into a distinct route: an older server
|
|
15272
16483
|
// returns 404 rather than silently ignoring safety preconditions.
|
|
15273
|
-
|
|
15274
|
-
|
|
15275
|
-
|
|
15276
|
-
|
|
15277
|
-
|
|
15278
|
-
|
|
15279
|
-
}).strict().safeParse(body).success) {
|
|
15280
|
-
return json(res, 400, { error: "guarded sends require threadId, sendId, text and expectedActiveLeafId" });
|
|
16484
|
+
const guardedBody = guarded ? guardedSendSchema.safeParse(body) : null;
|
|
16485
|
+
if (guardedBody && !guardedBody.success) {
|
|
16486
|
+
const onBehalfOfInvalid = guardedBody.error.issues.some((issue) => issue.path[0] === "onBehalfOf");
|
|
16487
|
+
return json(res, 400, { error: onBehalfOfInvalid
|
|
16488
|
+
? "onBehalfOf must name the person with an email address, a name, or both"
|
|
16489
|
+
: "guarded sends require threadId, sendId, text and expectedActiveLeafId" });
|
|
15281
16490
|
}
|
|
15282
16491
|
const text = String(body.text ?? "").trim();
|
|
15283
16492
|
if (!text)
|
|
@@ -15293,8 +16502,16 @@ const handleRequest = async (req, res) => {
|
|
|
15293
16502
|
// receipt after a task switch, while a genuinely new send still has to
|
|
15294
16503
|
// target the task that is active now.
|
|
15295
16504
|
const threadId = body.threadId ?? bot.threadId;
|
|
15296
|
-
|
|
15297
|
-
|
|
16505
|
+
// Who this message is from, for the ledger. It is captured here and
|
|
16506
|
+
// travels with the message: into the turn it starts, or into the queue
|
|
16507
|
+
// until it drains. A message steered into someone else's running turn
|
|
16508
|
+
// books nothing — that turn stays with whoever started it. A guarded
|
|
16509
|
+
// relay (the Slack worker, which already holds admin scope to reach
|
|
16510
|
+
// this route) names the person it acts for.
|
|
16511
|
+
const onBehalfOf = guardedBody?.data?.onBehalfOf;
|
|
16512
|
+
const trigger = onBehalfOf
|
|
16513
|
+
? { kind: "user", ...(onBehalfOf.email ? { email: onBehalfOf.email } : {}), ...(onBehalfOf.name ? { label: onBehalfOf.name } : {}) }
|
|
16514
|
+
: usageTriggerFor(auth);
|
|
15298
16515
|
// The send is acknowledged before the turn starts, so a workspace at its
|
|
15299
16516
|
// spend limit is refused here, where the person can see it.
|
|
15300
16517
|
try {
|
|
@@ -15345,8 +16562,10 @@ const handleRequest = async (req, res) => {
|
|
|
15345
16562
|
// There is no await between these checks and startTurn's
|
|
15346
16563
|
// synchronous transcript append / runtime reservation. In
|
|
15347
16564
|
// particular, never steer or enqueue under stale permissions.
|
|
15348
|
-
|
|
15349
|
-
|
|
16565
|
+
const expectedMode = body.expectedApprovalMode ?? "ask";
|
|
16566
|
+
if (approvalModeFor(currentAtStart) !== expectedMode ||
|
|
16567
|
+
(expectedMode === "ask" && (currentAtStart.autoApprove === true || currentAtStart.alwaysAllow?.length))) {
|
|
16568
|
+
throw Object.assign(new Error("guarded sends require the exact expected approval mode; Ask cannot have remembered permissions"), { status: 409, code: "guarded_permissions" });
|
|
15350
16569
|
}
|
|
15351
16570
|
if (store.activeLeaf(threadId) !== body.expectedActiveLeafId) {
|
|
15352
16571
|
throw Object.assign(new Error("the conversation changed before this message could start"), { status: 409, code: "guarded_branch" });
|
|
@@ -15354,8 +16573,7 @@ const handleRequest = async (req, res) => {
|
|
|
15354
16573
|
if (currentAtStart.busy || threadBusy(bot.id, threadId) || botAtThreadCapacity(bot.id) || parksBehindCoordination(bot.id, threadId) || activeGroupTurnForBot(bot.id)) {
|
|
15355
16574
|
throw Object.assign(new Error("wait for a free thread slot before retrying this message"), { status: 409, code: "guarded_busy" });
|
|
15356
16575
|
}
|
|
15357
|
-
|
|
15358
|
-
const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth) });
|
|
16576
|
+
const message = await startTurn(bot.id, text, { threadId, replyTo, sendId, sender: messageSender(auth), trigger });
|
|
15359
16577
|
return { ok: true, threadId, message };
|
|
15360
16578
|
}
|
|
15361
16579
|
// Claude can accept the message inside its live turn. If the write
|
|
@@ -15419,16 +16637,18 @@ const handleRequest = async (req, res) => {
|
|
|
15419
16637
|
return { ok: true, steered: true, threadId, message };
|
|
15420
16638
|
}
|
|
15421
16639
|
if (!current.busy) {
|
|
15422
|
-
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth));
|
|
16640
|
+
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
|
|
15423
16641
|
}
|
|
15424
16642
|
const queued = queueSteeredMessage(current.id, threadId, text, {
|
|
15425
16643
|
replyToId: replyTo?.id,
|
|
15426
16644
|
sendId,
|
|
15427
16645
|
prompt: promptWithReply(text, replyTo, cfg.profile?.name?.trim() || "User"),
|
|
16646
|
+
sender: messageSender(auth),
|
|
16647
|
+
trigger,
|
|
15428
16648
|
});
|
|
15429
16649
|
return { ok: true, queued: true, queueId: queued.id, threadId };
|
|
15430
16650
|
}
|
|
15431
|
-
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth));
|
|
16651
|
+
return startOrQueueDirectMessage(bot.id, threadId, text, replyTo, sendId, messageSender(auth), trigger);
|
|
15432
16652
|
});
|
|
15433
16653
|
return json(res, 202, receipt);
|
|
15434
16654
|
}
|
|
@@ -15450,7 +16670,9 @@ const handleRequest = async (req, res) => {
|
|
|
15450
16670
|
const body = await readBody(req);
|
|
15451
16671
|
requirePinnedClientThread(m[1], body?.threadId);
|
|
15452
16672
|
const bot = requestedTaskBot(m[1], body?.threadId);
|
|
15453
|
-
|
|
16673
|
+
// Pressing Steer folds the queued words into the running turn. It does
|
|
16674
|
+
// not re-book that turn to whoever pressed it, and the words keep the
|
|
16675
|
+
// sender they were queued with.
|
|
15454
16676
|
// Lift the whole queue atomically: a settle racing this request can
|
|
15455
16677
|
// drain it as a follow-up, or this request can steer it into the live
|
|
15456
16678
|
// turn — never both for the same words.
|
|
@@ -15494,6 +16716,7 @@ const handleRequest = async (req, res) => {
|
|
|
15494
16716
|
queueId: item.messageId,
|
|
15495
16717
|
peerAsk: item.peerAsk,
|
|
15496
16718
|
steered: true,
|
|
16719
|
+
sender: item.sender,
|
|
15497
16720
|
}));
|
|
15498
16721
|
// Offered to the next turn again unless the person stops this one.
|
|
15499
16722
|
for (const message of messages)
|
|
@@ -15525,9 +16748,8 @@ const handleRequest = async (req, res) => {
|
|
|
15525
16748
|
const body = await readBody(req);
|
|
15526
16749
|
requirePinnedClientThread(m[1], body?.threadId);
|
|
15527
16750
|
const bot = requestedTaskBot(m[1], body?.threadId);
|
|
15528
|
-
noteTurnTrigger(bot.threadId, auth);
|
|
15529
16751
|
await startTurn(bot.id, "Summarize conversation context", {
|
|
15530
|
-
threadId: bot.threadId, compactOnly: true, cardContinuation: true,
|
|
16752
|
+
threadId: bot.threadId, compactOnly: true, cardContinuation: true, trigger: usageTriggerFor(auth),
|
|
15531
16753
|
});
|
|
15532
16754
|
return json(res, 202, { ok: true, threadId: bot.threadId });
|
|
15533
16755
|
}
|
|
@@ -15561,10 +16783,14 @@ const handleRequest = async (req, res) => {
|
|
|
15561
16783
|
error: `provider instance "${bot.modelSelection.instanceId}" is unavailable — pick another model in settings`,
|
|
15562
16784
|
});
|
|
15563
16785
|
}
|
|
16786
|
+
const rerunInstance = registry.get(bot.modelSelection.instanceId);
|
|
16787
|
+
const rerunRefusal = policyModelRefusal(rerunInstance);
|
|
16788
|
+
if (rerunRefusal)
|
|
16789
|
+
return json(res, 409, { error: rerunRefusal });
|
|
15564
16790
|
// startTurn admits the rerun before branching. A shared-resource or
|
|
15565
16791
|
// concurrency-limit refusal must leave the original transcript intact.
|
|
15566
16792
|
const replyTo = source.replyToId ? resolveReplyTarget(bot.threadId, source.replyToId) : undefined;
|
|
15567
|
-
const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo });
|
|
16793
|
+
const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo, trigger: usageTriggerFor(auth) });
|
|
15568
16794
|
return json(res, 202, { ok: true, message });
|
|
15569
16795
|
}
|
|
15570
16796
|
// switch which fork of the conversation is visible (no new turn)
|
|
@@ -15594,44 +16820,49 @@ const handleRequest = async (req, res) => {
|
|
|
15594
16820
|
const reviewedSha256 = typeof body.reviewedSha256 === "string" ? body.reviewedSha256 : undefined;
|
|
15595
16821
|
if (!behavior)
|
|
15596
16822
|
return json(res, 400, { error: "behavior must be allow, deny, or answer" });
|
|
15597
|
-
|
|
15598
|
-
|
|
15599
|
-
|
|
15600
|
-
|
|
15601
|
-
|
|
15602
|
-
|
|
15603
|
-
|
|
15604
|
-
|
|
15605
|
-
|
|
15606
|
-
|
|
15607
|
-
|
|
15608
|
-
|
|
15609
|
-
|
|
15610
|
-
|
|
15611
|
-
|
|
15612
|
-
|
|
15613
|
-
|
|
15614
|
-
|
|
15615
|
-
|
|
15616
|
-
|
|
15617
|
-
|
|
15618
|
-
|
|
15619
|
-
|
|
15620
|
-
|
|
15621
|
-
|
|
15622
|
-
|
|
15623
|
-
|
|
15624
|
-
|
|
15625
|
-
|
|
15626
|
-
|
|
15627
|
-
|
|
15628
|
-
|
|
15629
|
-
|
|
15630
|
-
|
|
15631
|
-
|
|
15632
|
-
|
|
15633
|
-
|
|
15634
|
-
|
|
16823
|
+
const refusal = cardAnswerRefusal(auth, bot.threadId, String(body.requestId), behavior);
|
|
16824
|
+
if (refusal)
|
|
16825
|
+
return json(res, 403, { error: refusal });
|
|
16826
|
+
return await answeringCardAs(auth, bot.threadId, String(body.requestId), async () => {
|
|
16827
|
+
if (await resolveAndSendTeamSetup(res, {
|
|
16828
|
+
botId: bot.id, threadId: bot.threadId, requestId: String(body.requestId), behavior,
|
|
16829
|
+
}, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
|
|
16830
|
+
return;
|
|
16831
|
+
if (resolveAndSendRoutine(res, {
|
|
16832
|
+
botId: bot.id,
|
|
16833
|
+
botName: bot.name,
|
|
16834
|
+
threadId: bot.threadId,
|
|
16835
|
+
requestId: String(body.requestId),
|
|
16836
|
+
behavior,
|
|
16837
|
+
}))
|
|
16838
|
+
return;
|
|
16839
|
+
if (resolveAndSendProfile(res, {
|
|
16840
|
+
botId: bot.id,
|
|
16841
|
+
botName: bot.name,
|
|
16842
|
+
threadId: bot.threadId,
|
|
16843
|
+
requestId: String(body.requestId),
|
|
16844
|
+
behavior,
|
|
16845
|
+
}))
|
|
16846
|
+
return;
|
|
16847
|
+
if (sendSkillResolution(res, resolveSkillRequest({
|
|
16848
|
+
botId: bot.id,
|
|
16849
|
+
botName: bot.name,
|
|
16850
|
+
threadId: bot.threadId,
|
|
16851
|
+
requestId: String(body.requestId),
|
|
16852
|
+
behavior,
|
|
16853
|
+
reviewedSha256,
|
|
16854
|
+
})))
|
|
16855
|
+
return;
|
|
16856
|
+
// peer-approval intercept: harness-native cards carry a requestId
|
|
16857
|
+
// that lives in peer-approval's pending map. Resolve them here so
|
|
16858
|
+
// the provider adapter never sees a request it didn't raise.
|
|
16859
|
+
if (store.messagesFor(bot.threadId).some((message) => message.card?.requestId === String(body.requestId)) &&
|
|
16860
|
+
resolvePeerComms(approvalBus, String(body.requestId), behavior)) {
|
|
16861
|
+
return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
|
|
16862
|
+
}
|
|
16863
|
+
const outcome = await answerRequest(bot.threadId, bot.modelSelection.instanceId, String(body.requestId), behavior, body.message, { id: bot.id, name: bot.name }, body.always === true);
|
|
16864
|
+
return json(res, 200, { ok: true, outcome });
|
|
16865
|
+
});
|
|
15635
16866
|
}
|
|
15636
16867
|
// Answer by THREAD, so a request raised inside a room can be answered
|
|
15637
16868
|
// too: a member's turn runs on the room's thread, and the bot that
|
|
@@ -15645,86 +16876,91 @@ const handleRequest = async (req, res) => {
|
|
|
15645
16876
|
if (!behavior)
|
|
15646
16877
|
return json(res, 400, { error: "behavior must be allow, deny, or answer" });
|
|
15647
16878
|
const requestId = String(body.requestId);
|
|
15648
|
-
const
|
|
15649
|
-
if (
|
|
15650
|
-
|
|
15651
|
-
|
|
15652
|
-
|
|
15653
|
-
|
|
15654
|
-
|
|
15655
|
-
|
|
15656
|
-
|
|
15657
|
-
|
|
15658
|
-
|
|
15659
|
-
|
|
15660
|
-
|
|
15661
|
-
|
|
15662
|
-
|
|
15663
|
-
|
|
15664
|
-
|
|
15665
|
-
|
|
15666
|
-
|
|
15667
|
-
|
|
15668
|
-
|
|
15669
|
-
|
|
15670
|
-
|
|
15671
|
-
|
|
15672
|
-
|
|
15673
|
-
|
|
15674
|
-
|
|
15675
|
-
|
|
15676
|
-
|
|
15677
|
-
|
|
15678
|
-
|
|
15679
|
-
|
|
15680
|
-
|
|
15681
|
-
|
|
15682
|
-
|
|
15683
|
-
|
|
15684
|
-
|
|
15685
|
-
|
|
15686
|
-
|
|
15687
|
-
if (
|
|
15688
|
-
|
|
15689
|
-
|
|
15690
|
-
|
|
15691
|
-
|
|
15692
|
-
|
|
15693
|
-
|
|
15694
|
-
|
|
15695
|
-
|
|
15696
|
-
|
|
15697
|
-
|
|
15698
|
-
|
|
15699
|
-
|
|
15700
|
-
|
|
15701
|
-
|
|
15702
|
-
|
|
15703
|
-
|
|
15704
|
-
|
|
15705
|
-
|
|
15706
|
-
|
|
15707
|
-
|
|
15708
|
-
|
|
15709
|
-
|
|
15710
|
-
|
|
15711
|
-
|
|
15712
|
-
|
|
15713
|
-
|
|
15714
|
-
|
|
15715
|
-
|
|
15716
|
-
|
|
15717
|
-
|
|
15718
|
-
|
|
15719
|
-
|
|
15720
|
-
|
|
15721
|
-
|
|
15722
|
-
|
|
15723
|
-
|
|
15724
|
-
|
|
15725
|
-
|
|
15726
|
-
|
|
15727
|
-
|
|
16879
|
+
const refusal = cardAnswerRefusal(auth, threadId, requestId, behavior);
|
|
16880
|
+
if (refusal)
|
|
16881
|
+
return json(res, 403, { error: refusal });
|
|
16882
|
+
return await answeringCardAs(auth, threadId, requestId, async () => {
|
|
16883
|
+
const skillCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.skillRequest);
|
|
16884
|
+
if (skillCard?.card?.skillRequest) {
|
|
16885
|
+
const skillBotId = skillCard.from?.botId ?? store.botByThread(threadId)?.id;
|
|
16886
|
+
if (!skillBotId)
|
|
16887
|
+
return json(res, 400, { error: "this skill request has no valid owner" });
|
|
16888
|
+
const skillOwner = store.bot(skillBotId);
|
|
16889
|
+
if (sendSkillResolution(res, resolveSkillRequest({
|
|
16890
|
+
botId: skillBotId,
|
|
16891
|
+
botName: skillOwner?.name,
|
|
16892
|
+
threadId,
|
|
16893
|
+
requestId,
|
|
16894
|
+
behavior,
|
|
16895
|
+
reviewedSha256,
|
|
16896
|
+
})))
|
|
16897
|
+
return;
|
|
16898
|
+
}
|
|
16899
|
+
const routineCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.routineRequest);
|
|
16900
|
+
if (routineCard?.card?.routineRequest) {
|
|
16901
|
+
// Derive the owner from the conversation, not from the executable
|
|
16902
|
+
// payload being authorized. Room cards carry their trusted sender;
|
|
16903
|
+
// one-to-one tasks resolve through the store's thread ownership.
|
|
16904
|
+
const routineBotId = routineCard.from?.botId ?? store.botByThread(threadId)?.id;
|
|
16905
|
+
if (!routineBotId)
|
|
16906
|
+
return json(res, 400, { error: "this routine request has no valid owner" });
|
|
16907
|
+
const routineOwner = store.bot(routineBotId);
|
|
16908
|
+
if (resolveAndSendRoutine(res, {
|
|
16909
|
+
botId: routineBotId,
|
|
16910
|
+
botName: routineOwner?.name,
|
|
16911
|
+
threadId,
|
|
16912
|
+
requestId,
|
|
16913
|
+
behavior,
|
|
16914
|
+
}))
|
|
16915
|
+
return;
|
|
16916
|
+
}
|
|
16917
|
+
const setupCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamSetupRequest);
|
|
16918
|
+
if (setupCard) {
|
|
16919
|
+
const setupBotId = setupCard.from?.botId ?? store.botByThread(threadId)?.id;
|
|
16920
|
+
if (!setupBotId)
|
|
16921
|
+
return json(res, 400, { error: "This team setup has no valid owner" });
|
|
16922
|
+
if (await resolveAndSendTeamSetup(res, { botId: setupBotId, threadId, requestId, behavior }, auth.kind === "loopback" ? DESKTOP_MANAGED || Boolean(req.headers.origin) && !store.bots.some((bot) => bot.busy || activeGroupTurnForBot(bot.id)) : auth.scopes.includes("admin")))
|
|
16923
|
+
return;
|
|
16924
|
+
}
|
|
16925
|
+
const profileCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.profileRequest);
|
|
16926
|
+
if (profileCard?.card?.profileRequest) {
|
|
16927
|
+
const profileBotId = profileCard.from?.botId ?? store.botByThread(threadId)?.id;
|
|
16928
|
+
if (!profileBotId)
|
|
16929
|
+
return json(res, 400, { error: "this profile request has no valid owner" });
|
|
16930
|
+
const profileOwner = store.bot(profileBotId);
|
|
16931
|
+
if (resolveAndSendProfile(res, {
|
|
16932
|
+
botId: profileBotId,
|
|
16933
|
+
botName: profileOwner?.name,
|
|
16934
|
+
threadId,
|
|
16935
|
+
requestId,
|
|
16936
|
+
behavior,
|
|
16937
|
+
}))
|
|
16938
|
+
return;
|
|
16939
|
+
}
|
|
16940
|
+
// peer-approval intercept (see /api/bots/:id/respond above). A peer card
|
|
16941
|
+
// belongs to the bus rather than to a speaker, so resolve it before we go
|
|
16942
|
+
// looking for one — a room between turns has no speaker to find.
|
|
16943
|
+
if (store.messagesFor(threadId).some((message) => message.card?.requestId === requestId) &&
|
|
16944
|
+
resolvePeerComms(approvalBus, requestId, behavior)) {
|
|
16945
|
+
return json(res, 200, { ok: true, outcome: behavior === "allow" ? "allowed-once" : "rejected" });
|
|
16946
|
+
}
|
|
16947
|
+
const group = store.groupByThread(threadId);
|
|
16948
|
+
// busyBotId is in-memory only, so an approval that outlives its turn — or
|
|
16949
|
+
// the process — leaves a durable card with no speaker behind it. Fall back
|
|
16950
|
+
// to the member that raised it, and answer even when that member is gone:
|
|
16951
|
+
// answerRequest closes an unreachable card, and a pending approval owns
|
|
16952
|
+
// the composer, so a dead end here locks the room for good.
|
|
16953
|
+
const pending = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId);
|
|
16954
|
+
const owner = group
|
|
16955
|
+
? (group.busyBotId ? store.bot(group.busyBotId) : undefined) ??
|
|
16956
|
+
(pending?.from ? store.bot(pending.from.botId) : undefined)
|
|
16957
|
+
: store.botByThread(threadId);
|
|
16958
|
+
if (!owner && !pending)
|
|
16959
|
+
return json(res, 404, { error: "nothing is waiting on an answer in this conversation" });
|
|
16960
|
+
const requestOwner = owner ? botForThread(owner.id, threadId) : null;
|
|
16961
|
+
const outcome = await answerRequest(threadId, requestOwner?.modelSelection.instanceId ?? "", requestId, behavior, body.message, owner ? { id: owner.id, name: owner.name } : undefined, body.always === true);
|
|
16962
|
+
return json(res, 200, { ok: true, outcome });
|
|
16963
|
+
});
|
|
15728
16964
|
}
|
|
15729
16965
|
m = path.match(/^\/api\/bots\/([\w-]+)\/interrupt$/);
|
|
15730
16966
|
if (m && method === "POST") {
|
|
@@ -15855,18 +17091,36 @@ const handleRequest = async (req, res) => {
|
|
|
15855
17091
|
m = path.match(/^\/api\/bots\/([\w-]+)\/tasks$/);
|
|
15856
17092
|
if (m && method === "POST") {
|
|
15857
17093
|
const body = await readBody(req);
|
|
17094
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
17095
|
+
return json(res, 400, { error: "body must be a JSON object" });
|
|
15858
17096
|
const bot = store.bot(m[1]);
|
|
15859
17097
|
if (!bot)
|
|
15860
17098
|
return json(res, 404, { error: "no such bot" });
|
|
17099
|
+
if (body.approvalMode !== undefined && body.approvalMode !== "ask" && body.approvalMode !== "full") {
|
|
17100
|
+
return json(res, 400, { error: "new task approvalMode must be ask or full" });
|
|
17101
|
+
}
|
|
17102
|
+
if (body.approvalMode === "full") {
|
|
17103
|
+
if (auth.kind !== "loopback" || !sharedWorkspaceFullAccessEnabled()) {
|
|
17104
|
+
return json(res, 403, { error: "New Full tasks require the operator's dedicated shared-workspace policy" });
|
|
17105
|
+
}
|
|
17106
|
+
if (bot.approvalGrant)
|
|
17107
|
+
return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
|
|
17108
|
+
if (!supportsApprovalMode(registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, "full")) {
|
|
17109
|
+
return json(res, 400, { error: "This provider does not support Full access" });
|
|
17110
|
+
}
|
|
17111
|
+
}
|
|
15861
17112
|
if (phoneSecretSubmissions.hasBot(bot.id)) {
|
|
15862
17113
|
return json(res, 409, { error: "this bot is securely saving a credential — try again when it finishes" });
|
|
15863
17114
|
}
|
|
15864
17115
|
if (body.projectId !== undefined && (typeof body.projectId !== "string" || !store.project(bot.id, body.projectId))) {
|
|
15865
17116
|
return json(res, 400, { error: "projectId must belong to this bot" });
|
|
15866
17117
|
}
|
|
15867
|
-
const task = store.createTask(bot.id, typeof body.title === "string" ? body.title : undefined, true, body.projectId);
|
|
17118
|
+
const task = store.createTask(bot.id, typeof body.title === "string" ? body.title : undefined, true, body.projectId, undefined, body.approvalMode);
|
|
15868
17119
|
if (!task)
|
|
15869
17120
|
return json(res, 500, { error: "couldn't create that task" });
|
|
17121
|
+
// Who opened it decides who may answer its cards on a shared workspace.
|
|
17122
|
+
if (auth.kind === "session")
|
|
17123
|
+
threadStarters.set(task.threadId, personKey(auth.session));
|
|
15870
17124
|
const fresh = botWithThread(store.bot(bot.id));
|
|
15871
17125
|
broadcast({ kind: "bot", bot: fresh });
|
|
15872
17126
|
return json(res, 201, { bot: fresh, task: wireTask(task) });
|
|
@@ -15909,7 +17163,7 @@ const handleRequest = async (req, res) => {
|
|
|
15909
17163
|
const current = store.projectBotForTask(m[1], m[2]);
|
|
15910
17164
|
if (!current)
|
|
15911
17165
|
return json(res, 404, { error: "no such task" });
|
|
15912
|
-
const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "surface"]);
|
|
17166
|
+
const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "surface"]);
|
|
15913
17167
|
if (Object.keys(body).some((key) => !allowed.has(key)))
|
|
15914
17168
|
return json(res, 400, { error: "unsupported thread setting" });
|
|
15915
17169
|
for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
|
|
@@ -15946,6 +17200,11 @@ const handleRequest = async (req, res) => {
|
|
|
15946
17200
|
else
|
|
15947
17201
|
return json(res, 400, { error: "archivedAt must be a timestamp, or null to unarchive" });
|
|
15948
17202
|
}
|
|
17203
|
+
if (body.pinned !== undefined) {
|
|
17204
|
+
if (typeof body.pinned !== "boolean")
|
|
17205
|
+
return json(res, 400, { error: "pinned must be a boolean" });
|
|
17206
|
+
patch.pinned = body.pinned ? true : undefined;
|
|
17207
|
+
}
|
|
15949
17208
|
if (body.surface !== undefined) {
|
|
15950
17209
|
if (threadBusy(current.id, current.threadId))
|
|
15951
17210
|
return json(res, 409, { error: "Stop this thread before changing its computer destination." });
|
|
@@ -16008,7 +17267,7 @@ const handleRequest = async (req, res) => {
|
|
|
16008
17267
|
return json(res, checked.status, { error: checked.error });
|
|
16009
17268
|
}
|
|
16010
17269
|
const task = patch.modelSelection
|
|
16011
|
-
? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, patch)
|
|
17270
|
+
? store.switchTaskModel(m[1], m[2], patch.modelSelection, body.updateBotDefault === true, body.resetApprovalToAsk === true, { ...patch, ...hostedModels?.resetTask(current.modelSelection, patch.modelSelection) })
|
|
16012
17271
|
: store.patchTask(m[1], m[2], patch);
|
|
16013
17272
|
const fresh = botWithThread(store.bot(m[1]));
|
|
16014
17273
|
broadcast({ kind: "bot", bot: fresh });
|
|
@@ -16389,7 +17648,10 @@ const handleRequest = async (req, res) => {
|
|
|
16389
17648
|
// child proves it is OURS by echoing its pid (a stray dev server has
|
|
16390
17649
|
// the same API shape but a different pid)
|
|
16391
17650
|
if (method === "GET" && path === "/api/health") {
|
|
16392
|
-
return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR), capabilities: {
|
|
17651
|
+
return json(res, 200, { app: "openmausbot", pid: process.pid, static: Boolean(STATIC_DIR), capabilities: {
|
|
17652
|
+
guardedMessages: 1, guardedRequests: 1, guardedFullAccess: 1, guardedOnBehalfOf: 1,
|
|
17653
|
+
...(sharedWorkspaceFullAccessEnabled() ? { sharedWorkspaceFullAccess: 1 } : {}),
|
|
17654
|
+
} });
|
|
16393
17655
|
}
|
|
16394
17656
|
// The bots' browser engine: install it on this machine (agent-browser +
|
|
16395
17657
|
// a Chrome for Testing, a one-time download), or ask how that is going.
|
|
@@ -16446,7 +17708,9 @@ const handleRequest = async (req, res) => {
|
|
|
16446
17708
|
}
|
|
16447
17709
|
// Which edition this server runs and why (see server/enterprise.ts). Read-only.
|
|
16448
17710
|
if (method === "GET" && path === "/api/edition") {
|
|
16449
|
-
|
|
17711
|
+
// A member learns what is entitled; when the key lapses is for admins.
|
|
17712
|
+
const status = editionStatus();
|
|
17713
|
+
return json(res, 200, auth.scopes.includes("admin") ? status : editionForMembers(status));
|
|
16450
17714
|
}
|
|
16451
17715
|
// The brand for this deployment (server/brand.ts): read per request so edits show on reload.
|
|
16452
17716
|
if (method === "GET" && path === "/api/brand") {
|
|
@@ -16483,7 +17747,7 @@ const handleRequest = async (req, res) => {
|
|
|
16483
17747
|
return json(res, 400, { error: "from and to must be YYYY-MM-DD, from no later than to, at most a year apart" });
|
|
16484
17748
|
const rows = readUsage(DATA_DIR, range);
|
|
16485
17749
|
// The operator's price list is applied only with the billing entitlement.
|
|
16486
|
-
const prices =
|
|
17750
|
+
const prices = operatorPrices();
|
|
16487
17751
|
if (path === "/api/usage.csv") {
|
|
16488
17752
|
const stamp = (date) => date.toISOString().slice(0, 10);
|
|
16489
17753
|
res.writeHead(200, {
|
|
@@ -16540,6 +17804,57 @@ const handleRequest = async (req, res) => {
|
|
|
16540
17804
|
}
|
|
16541
17805
|
return json(res, 200, { decisions: readDecisions(DATA_DIR, parsedLimit ?? 200) });
|
|
16542
17806
|
}
|
|
17807
|
+
// Settings → Activity: admin actions and approvals together, filtered by
|
|
17808
|
+
// who, what and when; the same list as CSV. Admin scope by default.
|
|
17809
|
+
if (method === "GET" && (path === "/api/admin-activity" || path === "/api/admin-activity.csv")) {
|
|
17810
|
+
const fromParam = url.searchParams.get("from") || new Date(Date.now() - 29 * 24 * 60 * 60_000).toISOString().slice(0, 10);
|
|
17811
|
+
const range = parseUsageRange(fromParam, url.searchParams.get("to"));
|
|
17812
|
+
if (!range)
|
|
17813
|
+
return json(res, 400, { error: "from and to must be dates (YYYY-MM-DD), from no later than to, at most a year apart" });
|
|
17814
|
+
const what = parseActivityWhat(url.searchParams.get("what"));
|
|
17815
|
+
if (!what)
|
|
17816
|
+
return json(res, 400, { error: "what must be all, approvals, decisions, config, people, session, webhook, mcp, engine, bot, budget or visibility" });
|
|
17817
|
+
const entries = activityEntries(readDecisionRange(DATA_DIR, range), readAdminActivityRange(DATA_DIR, range), {
|
|
17818
|
+
what,
|
|
17819
|
+
who: (url.searchParams.get("who") ?? "").slice(0, 200),
|
|
17820
|
+
});
|
|
17821
|
+
if (path.endsWith(".csv")) {
|
|
17822
|
+
const stamp = (date) => date.toISOString().slice(0, 10);
|
|
17823
|
+
res.writeHead(200, {
|
|
17824
|
+
"content-type": "text/csv; charset=utf-8",
|
|
17825
|
+
"content-disposition": `attachment; filename="activity-${stamp(range.from)}-${stamp(range.to)}.csv"`,
|
|
17826
|
+
"cache-control": "no-store",
|
|
17827
|
+
});
|
|
17828
|
+
res.end(activityCsv(entries));
|
|
17829
|
+
return;
|
|
17830
|
+
}
|
|
17831
|
+
const rawLimit = Number(url.searchParams.get("limit") ?? 500);
|
|
17832
|
+
const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 5_000) : 500;
|
|
17833
|
+
res.setHeader("cache-control", "no-store");
|
|
17834
|
+
return json(res, 200, {
|
|
17835
|
+
entries: entries.slice(0, limit),
|
|
17836
|
+
total: entries.length,
|
|
17837
|
+
from: range.from.toISOString(),
|
|
17838
|
+
to: range.to.toISOString(),
|
|
17839
|
+
retentionDays: boundRetentionDays(),
|
|
17840
|
+
recording: adminActivityRecording(),
|
|
17841
|
+
});
|
|
17842
|
+
}
|
|
17843
|
+
// The same rows for an auditor's spreadsheet, over a date range. Admin
|
|
17844
|
+
// scope by default (not in CLIENT_ALLOW, not a service route).
|
|
17845
|
+
if (method === "GET" && path === "/api/decisions.csv") {
|
|
17846
|
+
const range = parseUsageRange(url.searchParams.get("from"), url.searchParams.get("to"));
|
|
17847
|
+
if (!range)
|
|
17848
|
+
return json(res, 400, { error: "from and to must be YYYY-MM-DD, from no later than to, at most a year apart" });
|
|
17849
|
+
const stamp = (date) => date.toISOString().slice(0, 10);
|
|
17850
|
+
res.writeHead(200, {
|
|
17851
|
+
"content-type": "text/csv; charset=utf-8",
|
|
17852
|
+
"content-disposition": `attachment; filename="decisions-${stamp(range.from)}-${stamp(range.to)}.csv"`,
|
|
17853
|
+
"cache-control": "no-store",
|
|
17854
|
+
});
|
|
17855
|
+
res.end(decisionsCsv(readDecisionRange(DATA_DIR, range)));
|
|
17856
|
+
return;
|
|
17857
|
+
}
|
|
16543
17858
|
// ── provider instances (model picker) ──
|
|
16544
17859
|
if (method === "GET" && path === "/api/instances") {
|
|
16545
17860
|
// Rescan PATH first: this endpoint is how the app answers "what can I
|
|
@@ -16550,6 +17865,8 @@ const handleRequest = async (req, res) => {
|
|
|
16550
17865
|
return json(res, 200, { instances: await describeInstances() });
|
|
16551
17866
|
}
|
|
16552
17867
|
const companyMutation = /^\/api\/instances\/(company\.[\w.-]+)(?:\/|$)/.exec(path);
|
|
17868
|
+
if (hostedModels && path.startsWith("/api/instances/") && method !== "GET")
|
|
17869
|
+
return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
|
|
16553
17870
|
if (companyMutation && method !== "GET")
|
|
16554
17871
|
return json(res, 403, { error: "Company accounts are read-only here. Manage this connection in desktop Settings." });
|
|
16555
17872
|
const instanceIconPatch = /^\/api\/instances\/([\w.-]+)\/icon$/.exec(path);
|
|
@@ -16882,6 +18199,9 @@ const handleRequest = async (req, res) => {
|
|
|
16882
18199
|
const parsed = parseMcpServerMutation(name, mcpServerBody(body));
|
|
16883
18200
|
if (!parsed.ok)
|
|
16884
18201
|
return json(res, 400, { error: parsed.error });
|
|
18202
|
+
const refusal = mcpPolicyRefusal(name, parsed.server);
|
|
18203
|
+
if (refusal)
|
|
18204
|
+
return json(res, 403, { error: refusal, code: "managed_policy" });
|
|
16885
18205
|
persistMcpServers({ ...current, [name]: parsed.server });
|
|
16886
18206
|
return json(res, 201, mcpServerResponse());
|
|
16887
18207
|
}
|
|
@@ -16918,6 +18238,9 @@ const handleRequest = async (req, res) => {
|
|
|
16918
18238
|
if (Object.keys(current).length + names.length > MAX_MCP_SERVERS) {
|
|
16919
18239
|
return json(res, 400, { error: `You can add at most ${MAX_MCP_SERVERS} MCP servers.` });
|
|
16920
18240
|
}
|
|
18241
|
+
const refused = names.filter(name => mcpPolicyRefusal(name, parsed.servers[name]));
|
|
18242
|
+
if (refused.length)
|
|
18243
|
+
return json(res, 403, { error: `${mcpPolicyRefusal(refused[0], parsed.servers[refused[0]])} Not approved: ${refused.join(", ")}.`, code: "managed_policy" });
|
|
16921
18244
|
persistMcpServers({ ...current, ...parsed.servers });
|
|
16922
18245
|
return json(res, 201, { ...mcpServerResponse(), added: names });
|
|
16923
18246
|
}
|
|
@@ -16959,6 +18282,9 @@ const handleRequest = async (req, res) => {
|
|
|
16959
18282
|
const parsed = parseMcpServerMutation(name, body, existing.server);
|
|
16960
18283
|
if (!parsed.ok)
|
|
16961
18284
|
return json(res, 400, { error: parsed.error });
|
|
18285
|
+
const refusal = mcpPolicyRefusal(name, parsed.server);
|
|
18286
|
+
if (refusal)
|
|
18287
|
+
return json(res, 403, { error: refusal, code: "managed_policy" });
|
|
16962
18288
|
persistMcpServers({ ...current, [name]: parsed.server });
|
|
16963
18289
|
return json(res, 200, mcpServerResponse());
|
|
16964
18290
|
}
|
|
@@ -16972,7 +18298,14 @@ const handleRequest = async (req, res) => {
|
|
|
16972
18298
|
}
|
|
16973
18299
|
if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
|
|
16974
18300
|
const body = await readBody(req);
|
|
18301
|
+
if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "opencodeGo"].some(key => Object.hasOwn(body, key)))
|
|
18302
|
+
return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
|
|
16975
18303
|
const patch = parseConfigPatch(body);
|
|
18304
|
+
if (hostedModels && patch.defaultModelSelection) {
|
|
18305
|
+
const checked = checkedModelSelection(patch.defaultModelSelection);
|
|
18306
|
+
if (!checked.ok)
|
|
18307
|
+
return json(res, checked.status, { error: checked.error });
|
|
18308
|
+
}
|
|
16976
18309
|
if (!Object.keys(patch).length)
|
|
16977
18310
|
return json(res, 400, { error: "nothing to save" });
|
|
16978
18311
|
const changingVoiceProvider = patch.tts?.provider !== undefined
|
|
@@ -17092,21 +18425,22 @@ const handleRequest = async (req, res) => {
|
|
|
17092
18425
|
}
|
|
17093
18426
|
}
|
|
17094
18427
|
let currentBoxInventory = null;
|
|
18428
|
+
let replacingRejectedBoxToken = false;
|
|
17095
18429
|
let currentBoxResources = null;
|
|
17096
18430
|
const journalBoxResources = [];
|
|
17097
18431
|
const deletingBoxIds = new Set(boxDeletions.map((entry) => entry.boxId));
|
|
17098
18432
|
if (changingBoxToken && currentBoxToken) {
|
|
17099
18433
|
currentBoxInventory = await box.listManagedBoxes({ box: { token: currentBoxToken } }, managedBoxOwners());
|
|
17100
18434
|
if (!currentBoxInventory.available) {
|
|
17101
|
-
|
|
18435
|
+
replacingRejectedBoxToken = currentBoxInventory.credentialRejected === true && Boolean(nextBoxToken);
|
|
18436
|
+
if (!replacementProvedByDeletion && !replacingRejectedBoxToken) {
|
|
17102
18437
|
return json(res, 503, {
|
|
17103
18438
|
error: `${currentBoxInventory.problem ?? "cloud computer inventory is unavailable"}. Keep the current Box account and retry`,
|
|
17104
18439
|
});
|
|
17105
18440
|
}
|
|
17106
|
-
//
|
|
17107
|
-
//
|
|
17108
|
-
//
|
|
17109
|
-
// an inventory request made with the expired token.
|
|
18441
|
+
// A rejected old key cannot authorize its own rotation. Below,
|
|
18442
|
+
// validate the replacement and prove access to remembered Boxes;
|
|
18443
|
+
// pending deletions retain their target-bound verification.
|
|
17110
18444
|
currentBoxInventory = null;
|
|
17111
18445
|
}
|
|
17112
18446
|
if (currentBoxInventory) {
|
|
@@ -17189,7 +18523,14 @@ const handleRequest = async (req, res) => {
|
|
|
17189
18523
|
if (!check.ok)
|
|
17190
18524
|
return json(res, 400, { error: check.message });
|
|
17191
18525
|
}
|
|
17192
|
-
if (
|
|
18526
|
+
if (replacingRejectedBoxToken) {
|
|
18527
|
+
// An expired credential cannot authorize its own replacement. Probe
|
|
18528
|
+
// the new account without adopting or retiring any local identities.
|
|
18529
|
+
const replacement = await box.listManagedBoxes({ box: { token: nextBoxToken } }, managedBoxOwners(), { adoptLegacy: false });
|
|
18530
|
+
if (!replacement.available)
|
|
18531
|
+
return json(res, 503, { error: replacement.problem ?? "Could not verify the replacement Box key. Your saved key and computers are unchanged." });
|
|
18532
|
+
}
|
|
18533
|
+
if (changingBoxToken && (!currentBoxToken || replacementProvedByDeletion || replacingRejectedBoxToken) && boxRecovery.length > 0) {
|
|
17193
18534
|
if (!nextBoxToken) {
|
|
17194
18535
|
return json(res, 409, { error: "restore the Box account that owns the remembered cloud computers before clearing it" });
|
|
17195
18536
|
}
|
|
@@ -17502,8 +18843,14 @@ const handleRequest = async (req, res) => {
|
|
|
17502
18843
|
}
|
|
17503
18844
|
// ── connectors (Composio) ──
|
|
17504
18845
|
if (method === "GET" && path === "/api/connectors/catalog") {
|
|
17505
|
-
const { cards, source } = await composio.listToolkits(cfg);
|
|
17506
|
-
return json(res, 200, {
|
|
18846
|
+
const { cards, source, pagination } = await composio.listToolkits(cfg);
|
|
18847
|
+
return json(res, 200, {
|
|
18848
|
+
configured: composio.configured(cfg),
|
|
18849
|
+
mode: composio.connectionMode(cfg),
|
|
18850
|
+
source,
|
|
18851
|
+
cards,
|
|
18852
|
+
...(pagination ? { pagination } : {}),
|
|
18853
|
+
});
|
|
17507
18854
|
}
|
|
17508
18855
|
if (method === "GET" && path === "/api/connectors/connected") {
|
|
17509
18856
|
const availability = composio.connectorAvailability(cfg);
|
|
@@ -17907,7 +19254,7 @@ const handleRequest = async (req, res) => {
|
|
|
17907
19254
|
catch (e) {
|
|
17908
19255
|
const status = e?.status ?? 500;
|
|
17909
19256
|
const candidateCode = e?.code;
|
|
17910
|
-
const code = typeof candidateCode === "string" && ["guarded_busy", "guarded_branch", "guarded_permissions"].includes(candidateCode)
|
|
19257
|
+
const code = typeof candidateCode === "string" && ["guarded_busy", "guarded_branch", "guarded_permissions", "guarded_request_changed", "guarded_request_untracked"].includes(candidateCode)
|
|
17911
19258
|
? candidateCode : undefined;
|
|
17912
19259
|
return json(res, status, { error: e instanceof Error ? e.message : String(e), ...(code ? { code } : {}) });
|
|
17913
19260
|
}
|
|
@@ -17919,6 +19266,28 @@ const server = createServer(handleRequest);
|
|
|
17919
19266
|
calendarCalls.start();
|
|
17920
19267
|
// Resolve the edition before accepting requests so /api/edition is never a guess.
|
|
17921
19268
|
console.log(describeEdition(await loadEnterpriseLayer()));
|
|
19269
|
+
// A key inside its last 30 days, or already in its grace period, says so
|
|
19270
|
+
// where an operator reads first.
|
|
19271
|
+
const startupLicenseWarning = licenseWarning(editionStatus());
|
|
19272
|
+
if (startupLicenseWarning)
|
|
19273
|
+
console.warn(startupLicenseWarning);
|
|
19274
|
+
console.log(LOOPBACK.trust === "service"
|
|
19275
|
+
? `local requests: service trust (${LOOPBACK.reason}); without a session, loopback may use only health, the Slack worker's guarded routes and bot capability routes`
|
|
19276
|
+
: `local requests: owner trust (${LOOPBACK.reason})`);
|
|
19277
|
+
if (LOOPBACK.warning)
|
|
19278
|
+
console.warn(`local requests: ${LOOPBACK.warning}`);
|
|
19279
|
+
// The decision log also prunes as it writes; this covers a quiet server.
|
|
19280
|
+
// Every room's floor, once, before any request: a room that was already
|
|
19281
|
+
// mixed when this server started keeps its narrowest audience from here on.
|
|
19282
|
+
for (const group of store.groups)
|
|
19283
|
+
settleRoomFloor(group);
|
|
19284
|
+
const pruneDecisionLog = () => {
|
|
19285
|
+
void pruneDecisions(DATA_DIR, decisionRetentionDays(cfg.decisions?.retentionDays));
|
|
19286
|
+
// The admin activity log keeps its months for the same window.
|
|
19287
|
+
void pruneAdminActivity(DATA_DIR, decisionRetentionDays(cfg.decisions?.retentionDays));
|
|
19288
|
+
};
|
|
19289
|
+
pruneDecisionLog();
|
|
19290
|
+
setInterval(pruneDecisionLog, 6 * 60 * 60_000).unref();
|
|
17922
19291
|
workspaceAccess = createWorkspaceAccess({ sessions, cookieName: SESSION_COOKIE, closeSessionStreams });
|
|
17923
19292
|
// Ten-second cadence plus the bridge's five-second backchannel deadline bounds
|
|
17924
19293
|
// stale portal access on quiet event/browser streams to fifteen seconds.
|
|
@@ -17990,7 +19359,7 @@ for (const row of chatFollowups()) {
|
|
|
17990
19359
|
const messages = store.messagesFor(row.threadId);
|
|
17991
19360
|
const recovered = messages.find((message) => message.queueId === row.id && message.role === "user") ?? store.appendMessage(row.threadId, {
|
|
17992
19361
|
role: "user", kind: "text", text: row.payload.text, replyToId: row.payload.replyToId,
|
|
17993
|
-
sendId: row.payload.sendId, queueId: row.id,
|
|
19362
|
+
sendId: row.payload.sendId, queueId: row.id, sender: row.payload.sender,
|
|
17994
19363
|
...(row.kind === "channel" ? { channelMode: row.payload.mode, via: row.payload.via } : {}),
|
|
17995
19364
|
});
|
|
17996
19365
|
// Nor as a message a resumed session has not seen: count it as handed.
|
|
@@ -18088,6 +19457,7 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
18088
19457
|
() => flushAllMemoryJournals(),
|
|
18089
19458
|
() => flushUsageLedger(DATA_DIR),
|
|
18090
19459
|
() => flushDecisionLog(DATA_DIR),
|
|
19460
|
+
() => flushAdminActivity(DATA_DIR),
|
|
18091
19461
|
],
|
|
18092
19462
|
// Cleanup jobs run concurrently. Release only after they settle (or reach
|
|
18093
19463
|
// the shutdown deadline), immediately before the process exits, so no new
|