openmausbot 0.1.91 → 0.1.93
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/DesktopViewer-CKNR3SEW.js +5 -0
- package/dist/assets/{abnfDiagram-O67JEVCF-yP-w8pRq.js → abnfDiagram-O67JEVCF-Ckrm1I1X.js} +1 -1
- package/dist/assets/{arc-DsivfZEh.js → arc-BBdBuCgB.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CILw7lzT.js → architectureDiagram-NJMV4G6O-CDdA3Hjs.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DEQUV3HF.js → blockDiagram-BEXU5L5S-AUrsx1YR.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-lSXCZoKA.js → c4Diagram-YGBWAQC7-BEqQ0Unh.js} +1 -1
- package/dist/assets/channel-BU94VpxE.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-QCD5bgWP.js → chunk-2Q5K7J3B-CEEuvd4C.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-C5-iZJuL.js → chunk-3FUC2YCW-DyANCiHl.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-cxsawMpY.js → chunk-5VM5RSS4-Dx_Slpw9.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-DV8tV8H7.js → chunk-GTNCS2PH-Dl7h7iiD.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-CJFZyfbW.js → chunk-GWA4HPMP-BLx07SPb.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-BigbMXco.js → chunk-JWPE2WC7-BMxDj3n7.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-oVxTfpDy.js → chunk-NETBCI7D-CN66PT4H.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CH4BiroW.js → chunk-WEXAMYUT-6HwhsDKw.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-KEDa6gax.js → chunk-XXDRQBXY-gWnLt7SN.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BiPjfqVv.js → classDiagram-v2-NBCMYWYE-CMS5l2ea.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-Caxew3OY.js → cose-bilkent-JH36ORCC-CV7sQwIG.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-B_Pui369.js → cynefin-OW5HDTMX-CSyl-7vl.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-X64ydIhP.js → cynefinDiagram-VND7K2PF-BCLKL6az.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-TqYCI1E1.js → dagre-6A5THRUB-DiaZs6S1.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-TeOt3rAQ.js → diagram-22UHCM2B-BeTtmt3I.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-D70BLhDA.js → diagram-3UASUU5V-CsdBG-QQ.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BbQDLwUC.js → diagram-ATOU4E4O-BczvdbR1.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-ChNJZ1u-.js → diagram-CDSNMT55-DHTTTMhi.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-DMBl2APa.js → diagram-MLGK6HIB-RTjHyd8z.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-CEqDchOp.js → diagram-MPIPVDR6-BDenHEH_.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-BVDKRp_-.js → ebnfDiagram-ZINNZB2B-BSiRSbtL.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CRkofO9B.js → elk-276RUBZZ-BCAElss-.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-DruOMh5e.js → erDiagram-OPXOYQCR-C4_1ZPJe.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-8GMQeB7t.js → flowDiagram-KWPJA3E3-Bcb4Z75S.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-ORE9jcsM.js → ganttDiagram-FUAMR5RP-BiPOzxzb.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-BmsedsWh.js → gitGraphDiagram-X574FWY7-B1wtF3ZS.js} +1 -1
- package/dist/assets/index-Biji-bfL.css +1 -0
- package/dist/assets/{index-DKNEM8Q6.js → index-BkYwilkG.js} +1 -1
- package/dist/assets/index-DZlhoES0.js +597 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-E_p7AooJ.js → infoDiagram-VRGFBTTK-A19No71U.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-Y-B2mKda.js → ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-eA8RELcK.js → journeyDiagram-ZHPQQLJL-DAO0pVan.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-DqIL2e8e.js → kanban-definition-PNTS6WVX-NepmKEe2.js} +1 -1
- package/dist/assets/{linear-yC_29R6e.js → linear-bq1PTDTW.js} +1 -1
- package/dist/assets/{mermaid.core-VhEMkSND.js → mermaid.core-s4LTbQle.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-oJ7kyQZy.js → mindmap-definition-NLK3R4M7-LtdbgHFH.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BmfYcY0O.js → pegDiagram-GJSIUBJH-tdbaCxiK.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DKvgP7w9.js → pieDiagram-5QR66LMP-DvkbpW8l.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-BEKL3W0L.js → quadrantDiagram-O4NWA36T-Do4KaH-E.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BEsiqR5s.js → railroadDiagram-XR7U4H2S-BQWULOAs.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DWBcWvAP.js → requirementDiagram-PLB6GJNP-w9kei1FU.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-rpZ2_O4s.js → sankeyDiagram-IPEJSGJF-D2gRy0e8.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-B99HQdJR.js → sequenceDiagram-PO4LG4MO-a2sO5_Eo.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BjN0qT5T.js → sizeCapture-INFHLROL-CiPJmFEB.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-BbsVgGsC.js → stateDiagram-v2-GCMORJYK-DAkUUlL7.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DG3gKkeR.js → swimlanes-2SLR337P-DANf2R8Y.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BTHOdtF_.js → timeline-definition-EJHVYXUP-BKpQmzrT.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-acLSLFwc.js → usecaseDiagram-POWQR4AR-BtI7qLN-.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-C5JzNX9G.js → vennDiagram-UO4OBE2U-gXJXrAG_.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DL1-PrR_.js → wardleyDiagram-VNRHLVJA-GU8Po5-n.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-Bvr1477p.js → xychartDiagram-PMCCYNJV-BF4J30g6.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/novnc-NOTICE.txt +118 -0
- package/dist-server/cloud-home-start.js +112 -14
- package/dist-server/companion/src/routes.js +24 -1
- package/dist-server/container-mcp.js +201 -16
- package/dist-server/drivers/agents-proxy.js +7 -4
- package/dist-server/enterprise/server/index.js +5 -1
- package/dist-server/index.js +10698 -4737
- package/dist-server/local-computer-proxy.js +133 -8
- package/dist-server/mcp-gate.js +16 -6
- package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
- package/dist-server/openmausbot.js +4656 -1258
- package/dist-server/pair-cli.js +4656 -1258
- package/dist-server/server/boat.js +5 -5
- package/dist-server/server/browser-engine.js +10 -1
- package/dist-server/server/browser-live.js +46 -2
- package/dist-server/server/browser-runtime.js +74 -8
- package/dist-server/server/browser-tool-shape.js +6 -4
- package/dist-server/server/chief-of-staff.js +1 -1
- package/dist-server/server/cloud-home-start.js +118 -26
- package/dist-server/server/cloud-home.js +39 -3
- package/dist-server/server/cloud-lending.js +74 -10
- package/dist-server/server/cloud-owner.js +196 -0
- package/dist-server/server/cloud-secrets-boot.js +6 -0
- package/dist-server/server/cloud-secrets.js +65 -0
- package/dist-server/server/computer-control.js +6 -0
- package/dist-server/server/config.js +187 -14
- package/dist-server/server/container-computer.js +46 -34
- package/dist-server/server/desktop-viewer-targets.js +26 -0
- package/dist-server/server/digest.js +12 -2
- package/dist-server/server/drivers/acp/core.js +339 -33
- package/dist-server/server/drivers/acp/cursor.js +13 -1
- package/dist-server/server/drivers/acp/opencode-go.js +614 -85
- package/dist-server/server/drivers/acp/quiet-status.js +257 -0
- package/dist-server/server/drivers/acp/qwen.js +18 -0
- package/dist-server/server/drivers/agents-catalog.js +6 -6
- package/dist-server/server/drivers/antigravity-acp.js +69 -11
- package/dist-server/server/drivers/antigravity-temp.js +360 -0
- package/dist-server/server/drivers/antigravity.js +21 -0
- package/dist-server/server/drivers/builtIn.js +2 -0
- package/dist-server/server/drivers/cerebras.js +108 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +20 -3
- package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
- package/dist-server/server/drivers/claude.js +329 -37
- package/dist-server/server/drivers/codex.js +228 -18
- package/dist-server/server/drivers/grok.js +2 -2
- package/dist-server/server/drivers/mistral.js +4 -3
- package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
- package/dist-server/server/drivers/openai-chat.js +66 -4
- package/dist-server/server/drivers/openai-compat.js +73 -23
- package/dist-server/server/drivers/prompt-split.js +2 -0
- package/dist-server/server/engine-install.js +46 -12
- package/dist-server/server/env-path.js +21 -5
- package/dist-server/server/harness/registry.js +2 -2
- package/dist-server/server/index.js +1545 -238
- package/dist-server/server/lending-memory.js +479 -0
- package/dist-server/server/local-vm-idle.js +24 -6
- package/dist-server/server/local-vm-stop-reason.js +26 -0
- package/dist-server/server/managed-desktop.js +3 -0
- package/dist-server/server/mcp-bridge.js +5 -3
- package/dist-server/server/mcp-http.js +6 -3
- package/dist-server/server/mcp-oauth-discovery.js +133 -0
- package/dist-server/server/mcp-oauth-store.js +70 -0
- package/dist-server/server/mcp-oauth.js +550 -0
- package/dist-server/server/mcp-probe.js +14 -1
- package/dist-server/server/mcp-registry.js +43 -0
- package/dist-server/server/memory-upkeep.js +94 -84
- package/dist-server/server/model-catalog/catalog.js +374 -0
- package/dist-server/server/model-catalog/presets.js +176 -0
- package/dist-server/server/model-catalog/trim.js +219 -0
- package/dist-server/server/provider-key-check.js +5 -2
- package/dist-server/server/recent-work.js +1 -1
- package/dist-server/server/request-auth.js +23 -0
- package/dist-server/server/routes/antigravity-leftovers.js +45 -0
- package/dist-server/server/routes/bot-memory.js +31 -6
- package/dist-server/server/routes/desktop-viewer.js +238 -0
- package/dist-server/server/routine-requests.js +1 -1
- package/dist-server/server/routines.js +6 -3
- package/dist-server/server/sessions.js +56 -9
- package/dist-server/server/skills.js +25 -10
- package/dist-server/server/store.js +116 -122
- package/dist-server/server/thread-events.js +2 -0
- package/dist-server/server/thread-starters.js +33 -0
- package/dist-server/server/thread-title.js +46 -0
- package/dist-server/server/tts/fish.js +4 -5
- package/dist-server/server/tts/index.js +5 -3
- package/dist-server/server/vps-computer.js +80 -13
- package/dist-server/server/webhooks.js +18 -1
- package/dist-server/server/workspace-backup-policy.js +3 -0
- package/dist-server/server/workspace-backup.js +25 -0
- package/dist-server/server/workspace.js +66 -16
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/redact.js +1 -0
- package/dist-server/shared/tool-name.js +22 -0
- package/dist-server/shared/workspace-backup-client.js +1 -0
- package/dist-server/src/lib/citations.js +177 -0
- package/dist-server/src/lib/composer-attachments.js +7 -1
- package/dist-server/vps-container-mcp.js +202 -16
- package/dist-server/workspace-backup.worker.js +87 -9
- package/enterprise/server/index.js +5 -1
- package/package.json +1 -1
- package/dist/assets/channel-DrfGMu7X.js +0 -1
- package/dist/assets/index-BlnCEcU6.js +0 -588
- package/dist/assets/index-DVxjOvID.css +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +0 -8
|
@@ -1,6 +1,9 @@
|
|
|
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
|
+
// First, before any module that could start a process: a Cloud home's
|
|
5
|
+
// secrets off the launcher's pipe (cloud-secrets-boot.ts).
|
|
6
|
+
import { BOOT_CLOUD_SECRETS } from "./cloud-secrets-boot.js";
|
|
4
7
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
8
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
9
|
import { existsSync, readFileSync, realpathSync, rmSync, mkdirSync } from "node:fs";
|
|
@@ -16,7 +19,8 @@ import { draftSummary, foldPoint } from "./compaction-summary.js";
|
|
|
16
19
|
import { compactBudget, contextWindowFor, shouldCompact } from "./context-budget.js";
|
|
17
20
|
import { autoCompactWindow } from "./drivers/claude.js";
|
|
18
21
|
import { provenRequestPerson, SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
|
|
19
|
-
import { cloudHomeLendingRefusal, createCloudRoutineAuthors } from "./cloud-lending.js";
|
|
22
|
+
import { cloudHomeLendingRefusal, createCloudRoutineAuthors, ownerOnlyConversation } from "./cloud-lending.js";
|
|
23
|
+
import { botMemoryFiles, createLendingMemory } from "./lending-memory.js";
|
|
20
24
|
import { SharedComputerControl } from "./shared-computer-control.js";
|
|
21
25
|
import { RoomHandoffs } from "./room-handoffs.js";
|
|
22
26
|
import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsInput, requestSourceForCard } from "./guarded-requests.js";
|
|
@@ -59,9 +63,9 @@ import { createMemoryUpkeep, upkeepEnabled } from "./memory-upkeep.js";
|
|
|
59
63
|
import { appendAboutMe, commitLearned, planLearned } from "./profile-learned.js";
|
|
60
64
|
import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, livePeerRosterBlock, peerAllowed, peerName, peerRosterSystemPrompt, peerStatus, peerStatusWords, reachablePeers, resolveTeammate, roomPeerRosterSystemPrompt, roomRosterLine, PEER_ACCESS_HELP } from "./peer-roster.js";
|
|
61
65
|
import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
|
|
62
|
-
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus,
|
|
66
|
+
import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmWakeAction, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
|
|
63
67
|
import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
|
|
64
|
-
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
|
|
68
|
+
import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, } from "./config.js";
|
|
65
69
|
import { sweepThreadEventLogs } from "./thread-retention.js";
|
|
66
70
|
import { ComputerControl } from "./computer-control.js";
|
|
67
71
|
import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
|
|
@@ -80,10 +84,12 @@ import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
|
|
|
80
84
|
import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
|
|
81
85
|
import { recoveryCapabilityError } from "./automatic-recovery.js";
|
|
82
86
|
import { decodeGeneratedImage } from "./generated-image.js";
|
|
83
|
-
import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
|
|
87
|
+
import { MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
|
|
88
|
+
import { McpOAuthError, McpSignInError, McpOAuthManager, mcpOAuthRedirectUri, withMcpSignIn, withoutPendingSignIn } from "./mcp-oauth.js";
|
|
84
89
|
import { probeMcpServer } from "./mcp-probe.js";
|
|
85
90
|
import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
|
|
86
91
|
import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
|
|
92
|
+
import { openCodeProviderKeysAllowed, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
|
|
87
93
|
import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
|
|
88
94
|
import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
|
|
89
95
|
import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
|
|
@@ -104,7 +110,8 @@ import { ProviderRegistry } from "./harness/registry.js";
|
|
|
104
110
|
import { ManagedDesktopProviders } from "./managed-desktop.js";
|
|
105
111
|
import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
|
|
106
112
|
import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
|
|
107
|
-
import { boatNotConfiguredMessage, CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, readSignedBody, } from "./cloud-home.js";
|
|
113
|
+
import { boatNotConfiguredMessage, CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
|
|
114
|
+
import { CLOUD_PERSONAL_REFUSAL, settleCloudOwnership } from "./cloud-owner.js";
|
|
108
115
|
import { createCloudMoveRoutes } from "./cloud-move-http.js";
|
|
109
116
|
import { holdIncludedServices } from "./included-services.js";
|
|
110
117
|
import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
|
|
@@ -120,10 +127,11 @@ import { narrateTool, toUtterances } from "./tts/speech-text.js";
|
|
|
120
127
|
import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
|
|
121
128
|
import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
|
|
122
129
|
import { extractTurnImages } from "./turn-images.js";
|
|
130
|
+
import { threadTitlePrompt, titleConversationExcerpt } from "./thread-title.js";
|
|
123
131
|
import { TurnWatchdog } from "./turn-watchdog.js";
|
|
124
132
|
import { TurnResources, workspaceResource } from "./turn-resources.js";
|
|
125
133
|
import { IdleReleasePolicy } from "./claim-idle.js";
|
|
126
|
-
import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
|
|
134
|
+
import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, TASK_WORKSPACES_DIR, readMemoryOnlyFromRegularFiles, workspaceDir, } from "./workspace.js";
|
|
127
135
|
import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
|
|
128
136
|
import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
|
|
129
137
|
import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
|
|
@@ -137,6 +145,7 @@ import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-
|
|
|
137
145
|
import { buildSystemPrompt, userProfileSystemPrompt, cloudHomePrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
|
|
138
146
|
import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
|
|
139
147
|
import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
|
|
148
|
+
import { localVmStopReason, recordLocalVmIdleStop } from "./local-vm-stop-reason.js";
|
|
140
149
|
import { LocalVmIdleTimer } from "./local-vm-idle.js";
|
|
141
150
|
import { LocalVmLeasePool } from "./local-vm-lease.js";
|
|
142
151
|
import { LocalVmSeatPool } from "./local-vm-seat-pool.js";
|
|
@@ -146,12 +155,12 @@ import * as vps from "./vps-computer.js";
|
|
|
146
155
|
import { RoutineManager } from "./routines.js";
|
|
147
156
|
import { CalendarCallManager } from "./calendar-calls.js";
|
|
148
157
|
import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
|
|
149
|
-
import { BrowserRuntime } from "./browser-runtime.js";
|
|
158
|
+
import { BrowserRuntime, browserRuntimeEnv } from "./browser-runtime.js";
|
|
150
159
|
import { BrowserLive } from "./browser-live.js";
|
|
151
|
-
import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
|
|
160
|
+
import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
|
|
152
161
|
import { createScreenFrameSource } from "./screen-frame-source.js";
|
|
153
162
|
import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
|
|
154
|
-
import { RoutineRequestService } from "./routine-requests.js";
|
|
163
|
+
import { asSchedule, RoutineRequestService } from "./routine-requests.js";
|
|
155
164
|
import { createOptionsCard } from "./options-card.js";
|
|
156
165
|
import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
|
|
157
166
|
import { ProfileRequestService } from "./profile-requests.js";
|
|
@@ -214,6 +223,10 @@ import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
|
|
|
214
223
|
import { createBotPresetRoutes } from "./routes/bot-presets.js";
|
|
215
224
|
import { createBotMemoryRoutes } from "./routes/bot-memory.js";
|
|
216
225
|
import { createDeciderRoutes } from "./routes/decider.js";
|
|
226
|
+
import { createDesktopViewer, desktopViewerUrl } from "./routes/desktop-viewer.js";
|
|
227
|
+
import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./desktop-viewer-targets.js";
|
|
228
|
+
import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
|
|
229
|
+
import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
|
|
217
230
|
const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
|
|
218
231
|
const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
|
|
219
232
|
// Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
|
|
@@ -223,6 +236,7 @@ const MIME = {
|
|
|
223
236
|
".html": "text/html",
|
|
224
237
|
".js": "text/javascript",
|
|
225
238
|
".css": "text/css",
|
|
239
|
+
".txt": "text/plain; charset=utf-8",
|
|
226
240
|
".svg": "image/svg+xml",
|
|
227
241
|
".png": "image/png",
|
|
228
242
|
".ico": "image/x-icon",
|
|
@@ -252,8 +266,12 @@ process.once("exit", releaseDataDirLeaseAtExit);
|
|
|
252
266
|
// Restore before constructing any long-lived config, Store, session or provider
|
|
253
267
|
// objects. Replacing files underneath a live Store would overwrite restored data.
|
|
254
268
|
let workspaceRestore = { restored: false };
|
|
269
|
+
/** A restore applied at this very start (not the last one on record). */
|
|
270
|
+
let restoredAtStart;
|
|
255
271
|
if (existsSync(join(DATA_DIR, ".backups"))) {
|
|
256
272
|
workspaceRestore = applyPendingWorkspaceRestore(DATA_DIR);
|
|
273
|
+
if (workspaceRestore.restored)
|
|
274
|
+
restoredAtStart = workspaceRestore.id;
|
|
257
275
|
if (!workspaceRestore.restored && !workspaceRestore.rolledBack) {
|
|
258
276
|
workspaceRestore = readLastWorkspaceRestore(DATA_DIR) ?? workspaceRestore;
|
|
259
277
|
}
|
|
@@ -279,7 +297,16 @@ const sessions = new SessionRegistry({
|
|
|
279
297
|
const sharedComputers = new SharedComputers(id => sessions.isLive(id));
|
|
280
298
|
// OMB Cloud Pro home machine (server/cloud-home.ts, docs/cloud-pro.md). A
|
|
281
299
|
// partial or invalid boot contract stops the server here, before it serves.
|
|
282
|
-
|
|
300
|
+
// Its secrets come over the launcher's pipe, never this process's
|
|
301
|
+
// environment (cloud-home-start.ts); a server started another way (tests,
|
|
302
|
+
// development) still reads them from its environment.
|
|
303
|
+
const CLOUD_SECRETS = BOOT_CLOUD_SECRETS;
|
|
304
|
+
const CLOUD_ENV = { ...process.env, ...CLOUD_SECRETS };
|
|
305
|
+
const CLOUD_HOME = cloudHomeConfiguration(CLOUD_ENV);
|
|
306
|
+
// A Cloud home is personal: only the owner's own devices, each with admin
|
|
307
|
+
// scope, connect (server/cloud-owner.ts; the stored rest is revoked below).
|
|
308
|
+
if (CLOUD_HOME)
|
|
309
|
+
sessions.requireAdmin(CLOUD_PERSONAL_REFUSAL);
|
|
283
310
|
/** Lending a computer to this server (the shared-computer routes, the two
|
|
284
311
|
* agent tools, the advertised capability): the maintainer flag anywhere, and
|
|
285
312
|
* always on an OMB Cloud home, where it is the person's own Mac lent to their
|
|
@@ -291,6 +318,29 @@ const CLOUD_HOME_LENDER = "cloud-home";
|
|
|
291
318
|
* the owner started: the only routine turns that may use a lent Mac. */
|
|
292
319
|
const cloudRoutineAuthors = CLOUD_HOME ? createCloudRoutineAuthors(join(DATA_DIR, "lending-routines.json")) : null;
|
|
293
320
|
const ownerStartedRoutineRuns = new Set();
|
|
321
|
+
/** Turns running now that are not provably the owner's, by generation. */
|
|
322
|
+
const foreignTurns = new Map();
|
|
323
|
+
/** Whether each bot's memory is still the owner's (server/lending-memory.ts):
|
|
324
|
+
* its memory files, and the instruction files its engine reads in the
|
|
325
|
+
* folders its conversations work in. */
|
|
326
|
+
const lendingMemory = CLOUD_HOME ? createLendingMemory({
|
|
327
|
+
file: join(DATA_DIR, "lending-memory.json"),
|
|
328
|
+
files: (botId) => {
|
|
329
|
+
const bot = store.bot(botId);
|
|
330
|
+
// The workspace as a turn dispatch leaves it: a bot's first turn seeds
|
|
331
|
+
// MEMORY.md, and that seed is not a change anyone made. Only the folders
|
|
332
|
+
// of conversations the owner opened: what a guest's own conversation
|
|
333
|
+
// writes in its own private folder reaches no turn of the owner's.
|
|
334
|
+
// (A bot's own conversations are never a room's: its opener is enough.)
|
|
335
|
+
const ownerOpened = (threadId) => { const starter = threadStarters.get(threadId); return starter === undefined || cloudOwnerPerson(starter); };
|
|
336
|
+
return botMemoryFiles(bot ? { id: bot.id, cwd: bot.cwd, tasks: store.tasks(bot.id).filter((task) => ownerOpened(task.threadId)) } : undefined, { workspace: ensureWorkspace, taskWorkspaces: TASK_WORKSPACES_DIR });
|
|
337
|
+
},
|
|
338
|
+
knownBots: () => store.bots.map((bot) => bot.id),
|
|
339
|
+
log: (line) => console.warn(line),
|
|
340
|
+
}) : null;
|
|
341
|
+
// On a Cloud home memory is never read through a link (server/workspace.ts).
|
|
342
|
+
if (CLOUD_HOME)
|
|
343
|
+
readMemoryOnlyFromRegularFiles();
|
|
294
344
|
const cloudPairing = CLOUD_HOME ? createCloudPairing({ secret: CLOUD_HOME.bootstrapSecret, sessions }) : null;
|
|
295
345
|
if (CLOUD_HOME) {
|
|
296
346
|
// The signing secret is held in memory from here on, and a platform
|
|
@@ -305,7 +355,12 @@ if (CLOUD_HOME) {
|
|
|
305
355
|
}
|
|
306
356
|
// Cloud Pro's included Boat, voice and decision relay tokens, when the Admin
|
|
307
357
|
// set them: held in memory from here on, like the signing secret.
|
|
308
|
-
holdIncludedServices();
|
|
358
|
+
holdIncludedServices(CLOUD_ENV);
|
|
359
|
+
for (const key of CLOUD_HOME_SECRET_KEYS)
|
|
360
|
+
delete process.env[key];
|
|
361
|
+
if (CLOUD_HOME && Object.keys(CLOUD_SECRETS).length === 0) {
|
|
362
|
+
console.warn("cloud home: its secrets came in this process's environment, which anything running as the same user can read in /proc; start it with cloud-home-start");
|
|
363
|
+
}
|
|
309
364
|
// Who each thread is for, when a signed-in person can be named (server-private).
|
|
310
365
|
const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
|
|
311
366
|
const commandAllowlist = new CommandAllowlistStore(join(DATA_DIR, "command-allowlist.json"));
|
|
@@ -318,7 +373,7 @@ const sharedWorkspaceFullAccessEnabled = () => SHARED_WORKSPACE_FULL_ACCESS && B
|
|
|
318
373
|
// Who a loopback request without a session is (server/request-auth.ts
|
|
319
374
|
// LoopbackTrust): the owner on a desktop or a one-person server; a service on
|
|
320
375
|
// a shared workspace, where every bot's shell is a loopback caller too.
|
|
321
|
-
const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE });
|
|
376
|
+
const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME) });
|
|
322
377
|
// `openmausbot serve` on a service-trust server hands the server it starts a
|
|
323
378
|
// per-launch secret on stdin, then closes it (server/cli.ts). It opens only
|
|
324
379
|
// the pairing route, for that CLI. Never an environment variable: every
|
|
@@ -416,7 +471,7 @@ function messageSender(auth) {
|
|
|
416
471
|
if (auth.kind !== "session")
|
|
417
472
|
return undefined;
|
|
418
473
|
const name = (auth.session.email ?? auth.session.label ?? "").trim();
|
|
419
|
-
return name ? { name, id:
|
|
474
|
+
return name ? { name, id: actorKey(auth) } : undefined;
|
|
420
475
|
}
|
|
421
476
|
/** An opaque, stable key for the person behind a session: their account
|
|
422
477
|
* email when they signed in with one (a new device is still them), else the
|
|
@@ -474,7 +529,7 @@ function sharedComputerPrincipal(capability) {
|
|
|
474
529
|
// their devices, or a routine they wrote. Never a guest, a webhook, a room
|
|
475
530
|
// or a line someone else slipped in.
|
|
476
531
|
if (CLOUD_HOME)
|
|
477
|
-
return
|
|
532
|
+
return cloudLendingRefusal(capability) === null ? CLOUD_HOME_LENDER : null;
|
|
478
533
|
return provenRequestPerson(directRequestOwners.get(capability.threadId), capability.generation, (messageId) => linePersonKey(store.messagesFor(capability.threadId).find((message) => message.id === messageId)));
|
|
479
534
|
}
|
|
480
535
|
/** What the lending rule needs to know about one bot turn on a Cloud home. */
|
|
@@ -483,7 +538,9 @@ function cloudLendingTurn(capability) {
|
|
|
483
538
|
request: directRequestOwners.get(capability.threadId),
|
|
484
539
|
generation: capability.generation,
|
|
485
540
|
thread: store.messagesFor(capability.threadId),
|
|
486
|
-
|
|
541
|
+
starters: cloudThreadStarters(capability.threadId),
|
|
542
|
+
reportsFromOthers: store.messagesFor(capability.threadId).some(cloudOthersRoutineReport),
|
|
543
|
+
ownerPerson: cloudProvenOwnerPerson,
|
|
487
544
|
cardAnswerer: cloudCardAnswerer,
|
|
488
545
|
routineRun: () => {
|
|
489
546
|
const run = activeRoutineRunForThread(capability.threadId);
|
|
@@ -493,9 +550,7 @@ function cloudLendingTurn(capability) {
|
|
|
493
550
|
triggerSource: run.triggerSource ?? (run.manual ? "manual" : "schedule"),
|
|
494
551
|
ownerStarted: ownerStartedRoutineRuns.has(run.id),
|
|
495
552
|
// What the run snapshotted, on the schedule its routine has now.
|
|
496
|
-
ownerAuthored: (
|
|
497
|
-
...routine, prompt: run.prompt, target: run.target, botId: run.botId, groupId: run.groupId, attachments: run.attachments, runOn: run.runOn,
|
|
498
|
-
}) === true)(routines?.listRoutines().find((candidate) => candidate.id === run.routineId)),
|
|
553
|
+
ownerAuthored: cloudRoutineRunIsOwners(run, capability.botId, capability.threadId),
|
|
499
554
|
};
|
|
500
555
|
},
|
|
501
556
|
};
|
|
@@ -503,6 +558,82 @@ function cloudLendingTurn(capability) {
|
|
|
503
558
|
/** What a bot on a Cloud home is told when its conversation cannot use the
|
|
504
559
|
* owner's lent Mac because someone else wrote in it. The bot relays it. */
|
|
505
560
|
const LENDING_SOMEONE_ELSE = "Someone else wrote in this conversation, so it can't use your Mac. Start a new conversation to use it.";
|
|
561
|
+
/** …or because its memory was changed where the owner did not write. */
|
|
562
|
+
const LENDING_MEMORY_CHANGED = "This bot's memory was changed in a conversation you didn't write. Review it in Memory to use your Mac again.";
|
|
563
|
+
/** Why this bot turn may not use the owner's lent Mac on a Cloud home, or
|
|
564
|
+
* null: the conversation's provenance (server/cloud-lending.ts), then the
|
|
565
|
+
* bot's memory, which every one of its turns loads (server/lending-memory.ts). */
|
|
566
|
+
function cloudLendingRefusal(capability) {
|
|
567
|
+
const refusal = cloudHomeLendingRefusal(cloudLendingTurn(capability));
|
|
568
|
+
if (refusal)
|
|
569
|
+
return refusal;
|
|
570
|
+
// Checked on every Mac action: one snapshot per tick is enough to look.
|
|
571
|
+
return lendingMemory?.reconcile(capability.botId, foreignTurnRunning(capability.botId), { cached: true }).changedBySomeoneElse ? "memory-changed" : null;
|
|
572
|
+
}
|
|
573
|
+
/** Who opened a conversation, and the room it is in: on a Cloud home a
|
|
574
|
+
* person who can name, title and steer it (see cloudThreadRefusal). */
|
|
575
|
+
function cloudThreadStarters(threadId) {
|
|
576
|
+
const room = store.groupByThread(threadId);
|
|
577
|
+
return [threadStarters.get(threadId), ...(room && room.threadId !== threadId ? [threadStarters.get(room.threadId)] : [])];
|
|
578
|
+
}
|
|
579
|
+
/** On a Cloud home: a turn that may use the owner's lent Mac, as far as its
|
|
580
|
+
* conversation goes. Such a turn reads only the owner's own conversations
|
|
581
|
+
* (session_search, session_read, list_threads). */
|
|
582
|
+
function cloudLendingView(capability) {
|
|
583
|
+
return CLOUD_HOME !== null && cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null;
|
|
584
|
+
}
|
|
585
|
+
/** On a Cloud home: a routine's report (its lifecycle card, carrying what
|
|
586
|
+
* the run said) of a routine the owner did not write, as it stands now. It
|
|
587
|
+
* brings someone else's instructions' results into the conversation it
|
|
588
|
+
* reports to. */
|
|
589
|
+
function cloudOthersRoutineReport(line) {
|
|
590
|
+
if (line.kind !== "routine.run" || !line.routineRun)
|
|
591
|
+
return false;
|
|
592
|
+
const routine = routines?.listRoutines().find((candidate) => candidate.id === line.routineRun.routineId);
|
|
593
|
+
return !routine || cloudRoutineAuthors?.authored(routine.id, routine) !== true;
|
|
594
|
+
}
|
|
595
|
+
/** On a Cloud home: a conversation of this bot that only its owner opened
|
|
596
|
+
* and wrote in (server/cloud-lending.ts ownerOnlyConversation), the only kind
|
|
597
|
+
* that may feed the bot's memory, daily log, recall or recent-work brief, or
|
|
598
|
+
* be quoted there by its title. Cached per thread until it gets another
|
|
599
|
+
* message, and dropped whenever a session is revoked. */
|
|
600
|
+
const ownerOnlyCache = new Map();
|
|
601
|
+
function cloudOwnerOnlyThread(threadId) {
|
|
602
|
+
if (cloudThreadStarters(threadId).some((person) => person !== undefined && !cloudProvenOwnerPerson(person)))
|
|
603
|
+
return false;
|
|
604
|
+
const thread = store.messagesFor(threadId);
|
|
605
|
+
const last = thread.at(-1);
|
|
606
|
+
const cached = ownerOnlyCache.get(threadId);
|
|
607
|
+
// A card answered later changes the answer too: key on its answerer as well.
|
|
608
|
+
const lastKey = last ? `${last.id}:${last.card?.answeredBy ? JSON.stringify(last.card.answeredBy) : ""}` : undefined;
|
|
609
|
+
if (cached && cached.length === thread.length && cached.last === lastKey)
|
|
610
|
+
return cached.owner;
|
|
611
|
+
const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(cloudOthersRoutineReport);
|
|
612
|
+
if (ownerOnlyCache.size > 5_000)
|
|
613
|
+
ownerOnlyCache.clear();
|
|
614
|
+
ownerOnlyCache.set(threadId, { length: thread.length, last: lastKey, owner });
|
|
615
|
+
return owner;
|
|
616
|
+
}
|
|
617
|
+
/** On a Cloud home: a turn that is not provably the owner's starts for this
|
|
618
|
+
* bot (its memory is "pending" until the turn's generation ends:
|
|
619
|
+
* server/lending-memory.ts; see revokeInternalCapabilityGeneration). */
|
|
620
|
+
function noteForeignTurn(botId, threadId, generation) {
|
|
621
|
+
if (!lendingMemory)
|
|
622
|
+
return;
|
|
623
|
+
foreignTurns.set(generation, { botId, threadId });
|
|
624
|
+
// The record is updated in memory before it is saved: a failed save still
|
|
625
|
+
// leaves the bot pending, and must not fail the turn that is starting
|
|
626
|
+
// (whose end is what clears this entry).
|
|
627
|
+
try {
|
|
628
|
+
lendingMemory.noteForeignTurn(botId);
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
console.warn(`lending: could not record a turn someone else started: ${error.message}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function foreignTurnRunning(botId) {
|
|
635
|
+
return [...foreignTurns.values()].some((turn) => turn.botId === botId);
|
|
636
|
+
}
|
|
506
637
|
/** Whose lent computers a status reader may see: on a Cloud home, only the
|
|
507
638
|
* owner's own devices (admin sessions) see the lent Mac, never a guest or a
|
|
508
639
|
* local process; elsewhere, the person behind the asking session. */
|
|
@@ -511,6 +642,239 @@ function sessionLendingPrincipal(auth) {
|
|
|
511
642
|
return cloudOwnerSession(auth) ? CLOUD_HOME_LENDER : null;
|
|
512
643
|
return auth.kind === "session" ? personKey(auth.session) : null;
|
|
513
644
|
}
|
|
645
|
+
/** Profile fields only the owner may change on a Cloud home: what rides the
|
|
646
|
+
* bot's prompt, and whether it notifies the owner. */
|
|
647
|
+
const CLOUD_OWNER_PROFILE_FIELDS = ["name", "title", "description", "soul", "notifications"];
|
|
648
|
+
/** On a Cloud home: why this request may not act on a conversation, or
|
|
649
|
+
* null. The owner's conversations feed the owner's turns (and a lent Mac),
|
|
650
|
+
* so only one of the owner's own devices may rename them, change how they
|
|
651
|
+
* run, rewrite or delete them; anyone else only a conversation they started
|
|
652
|
+
* themselves (a guest's own session). Sends are checked by
|
|
653
|
+
* cloudGuestSendRefusal instead. Elsewhere always null. */
|
|
654
|
+
function cloudThreadRefusal(auth, threadId) {
|
|
655
|
+
if (!CLOUD_HOME || cloudOwnerSession(auth))
|
|
656
|
+
return null;
|
|
657
|
+
if (auth.kind === "session" && threadStarters.get(threadId) === actorKey(auth))
|
|
658
|
+
return null;
|
|
659
|
+
return "On this Cloud you can only change conversations you started.";
|
|
660
|
+
}
|
|
661
|
+
/** On a Cloud home: why a paired guest may not send into a conversation, or
|
|
662
|
+
* null. A guest writes only in conversations it started, never in the
|
|
663
|
+
* owner's (a line steered into the owner's running turn would reach it
|
|
664
|
+
* before anything could reclassify that turn). Local services (loopback)
|
|
665
|
+
* keep their own conversations; their lines make a conversation someone
|
|
666
|
+
* else's all the same (see noteSomeoneElsesLine). */
|
|
667
|
+
function cloudGuestSendRefusal(auth, threadId) {
|
|
668
|
+
if (!CLOUD_HOME || auth.kind !== "session" || cloudOwnerSession(auth))
|
|
669
|
+
return null;
|
|
670
|
+
return threadStarters.get(threadId) === actorKey(auth)
|
|
671
|
+
? null : "On this Cloud you can only write in conversations you started. Start a new conversation with this bot.";
|
|
672
|
+
}
|
|
673
|
+
/** On a Cloud home: the one key every line, card answer, conversation and
|
|
674
|
+
* routine of the owner's own devices carries, whichever device and however
|
|
675
|
+
* often it was paired again, so re-pairing never demotes what the owner
|
|
676
|
+
* wrote. Only an admin session is given it: a guest keeps its own key. */
|
|
677
|
+
const CLOUD_OWNER_KEY = CLOUD_HOME ? `p_${createHash("sha256").update(`cloud-owner:${CLOUD_HOME.machineId}`).digest("base64url").slice(0, 22)}` : null;
|
|
678
|
+
/** …and the key of nobody at all: what a conversation made for work no
|
|
679
|
+
* person can be named for (a routine with no recorded writer) opens as. */
|
|
680
|
+
const CLOUD_NOBODY_KEY = `p_${createHash("sha256").update("cloud-nobody").digest("base64url").slice(0, 22)}`;
|
|
681
|
+
/** A Cloud home is personal (server/cloud-owner.ts): the keys its owner's
|
|
682
|
+
* devices carried before they shared one, settled at boot once the routines
|
|
683
|
+
* are loaded (settleCloudOwnership, below). `adopted` decides who opened a
|
|
684
|
+
* conversation, its level and its folder; only `proven` feeds lending and
|
|
685
|
+
* memory. Nothing is either until then. */
|
|
686
|
+
let cloudEarlierOwners = { adopted: new Set(), proven: new Set() };
|
|
687
|
+
/** Who a session acts as, for lines, openers and answers: on a Cloud home
|
|
688
|
+
* one of the owner's own devices is the owner; anyone else is themselves. */
|
|
689
|
+
function actorKey(auth) {
|
|
690
|
+
return CLOUD_OWNER_KEY && auth.scopes.includes("admin") ? CLOUD_OWNER_KEY : personKey(auth.session);
|
|
691
|
+
}
|
|
692
|
+
/** On a Cloud home: whoever is writing a routine in this request (routine
|
|
693
|
+
* creation and edits can open its results conversation). */
|
|
694
|
+
let routineWriterInFlight;
|
|
695
|
+
function withRoutineWriter(writer, work) {
|
|
696
|
+
const previous = routineWriterInFlight;
|
|
697
|
+
routineWriterInFlight = writer;
|
|
698
|
+
try {
|
|
699
|
+
return work();
|
|
700
|
+
}
|
|
701
|
+
finally {
|
|
702
|
+
routineWriterInFlight = previous;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
/** On a Cloud home: a routine the owner approved (its card, or a proposal
|
|
706
|
+
* applied at once in their own Full-access conversation) is theirs as it
|
|
707
|
+
* stands: their key as its writer, their fingerprint on it. */
|
|
708
|
+
function cloudOwnersRoutine(routineId) {
|
|
709
|
+
const routine = routineId ? routines?.listRoutines().find((candidate) => candidate.id === routineId) : undefined;
|
|
710
|
+
if (!routine || !cloudRoutineAuthors || !CLOUD_OWNER_KEY)
|
|
711
|
+
return;
|
|
712
|
+
cloudRoutineAuthors.record(routine.id, routine);
|
|
713
|
+
cloudRoutineAuthors.wrote(routine.id, CLOUD_OWNER_KEY);
|
|
714
|
+
}
|
|
715
|
+
/** On a Cloud home: a routine that is the owner's as it stands: their
|
|
716
|
+
* fingerprint matches what it runs, or they are its writer and no
|
|
717
|
+
* fingerprint of theirs was ever recorded (a template, a restore). */
|
|
718
|
+
function cloudRoutineIsOwners(routineId) {
|
|
719
|
+
const routine = routineId ? routines?.listRoutines().find((candidate) => candidate.id === routineId) : undefined;
|
|
720
|
+
if (!routine || !cloudRoutineAuthors || !CLOUD_OWNER_KEY)
|
|
721
|
+
return false;
|
|
722
|
+
return cloudRoutineAuthors.authored(routine.id, routine) ||
|
|
723
|
+
(cloudRoutineAuthors.writer(routine.id) === CLOUD_OWNER_KEY && !cloudRoutineAuthors.recorded(routine.id));
|
|
724
|
+
}
|
|
725
|
+
/** On a Cloud home: a routine run that acts for the owner. Only in the run's
|
|
726
|
+
* own thread, and only while the owner's fingerprint matches what the run
|
|
727
|
+
* snapshotted, the same test as lending the Mac. A routine that is the
|
|
728
|
+
* owner's but not yet cleared for the Mac (no fingerprint: a v0.1.91
|
|
729
|
+
* approval, a template, a restore) can't clear itself or others this way. */
|
|
730
|
+
function cloudRoutineRunIsOwners(run, botId, threadId) {
|
|
731
|
+
if (!run || run.threadId !== threadId || store.taskByThread(botId, threadId)?.routineRunId !== run.id || !cloudRoutineAuthors)
|
|
732
|
+
return false;
|
|
733
|
+
const routine = routines?.listRoutines().find((candidate) => candidate.id === run.routineId);
|
|
734
|
+
return Boolean(routine) && cloudRoutineAuthors.authored(run.routineId, {
|
|
735
|
+
...routine, prompt: run.prompt, target: run.target, botId: run.botId, groupId: run.groupId, attachments: run.attachments, runOn: run.runOn,
|
|
736
|
+
}) === true;
|
|
737
|
+
}
|
|
738
|
+
/** After the owner approved a routine request (its card, or at once in their
|
|
739
|
+
* own conversation): a routine it created is theirs; one it changed, paused
|
|
740
|
+
* or resumed stays theirs only if it already was (`wasOwners`, taken before
|
|
741
|
+
* the change). Approving a harmless change never makes anyone else's routine
|
|
742
|
+
* the owner's. */
|
|
743
|
+
function cloudOwnerApplied(action, routineId, wasOwners) {
|
|
744
|
+
if (action === "create" || ((action === "update" || action === "pause" || action === "resume") && wasOwners))
|
|
745
|
+
cloudOwnersRoutine(routineId);
|
|
746
|
+
}
|
|
747
|
+
/** What the owner saw on a card that proposed a routine, and what a routine
|
|
748
|
+
* runs, in the same terms (everything its fingerprint covers except an
|
|
749
|
+
* interval's anchor, which only moves when it runs). */
|
|
750
|
+
function approvalShape(shape) {
|
|
751
|
+
const schedule = shape.schedule && typeof shape.schedule === "object" ? { ...shape.schedule } : null;
|
|
752
|
+
if (schedule)
|
|
753
|
+
delete schedule.anchorAt;
|
|
754
|
+
return JSON.stringify([shape.prompt ?? "", shape.botId ?? "", shape.runOn ?? "maus", schedule, shape.target ?? "bot", shape.groupId ?? null,
|
|
755
|
+
(shape.attachments ?? []).map((attachment) => [attachment.id, attachment.path])]);
|
|
756
|
+
}
|
|
757
|
+
/** Who opens a routine's results conversation on a Cloud home: the writer
|
|
758
|
+
* of this request, else the owner for a routine that is theirs (they wrote
|
|
759
|
+
* it as it stands, or they are its writer: cloud-owner.ts), else its last
|
|
760
|
+
* writer, else nobody. */
|
|
761
|
+
function routineOpener(routineId) {
|
|
762
|
+
if (routineWriterInFlight)
|
|
763
|
+
return routineWriterInFlight;
|
|
764
|
+
const routine = routines?.listRoutines().find((candidate) => candidate.id === routineId);
|
|
765
|
+
if (routine && CLOUD_OWNER_KEY && cloudRoutineAuthors?.authored(routineId, routine))
|
|
766
|
+
return CLOUD_OWNER_KEY;
|
|
767
|
+
const writer = cloudRoutineAuthors?.writer(routineId);
|
|
768
|
+
if (writer && CLOUD_OWNER_KEY && writer === CLOUD_OWNER_KEY)
|
|
769
|
+
return CLOUD_OWNER_KEY;
|
|
770
|
+
return writer && !cloudOwnerPerson(writer) ? writer : CLOUD_NOBODY_KEY;
|
|
771
|
+
}
|
|
772
|
+
/** On a Cloud home: a conversation someone other than the owner opened (a
|
|
773
|
+
* guest, a guest's routine, or its room; on a personal Cloud, one a revoked
|
|
774
|
+
* session opened: nobody's). Its turns run in Ask, confined, in a private
|
|
775
|
+
* folder of their own (pinned at its first turn, or after it was unpinned at
|
|
776
|
+
* boot: see pinTaskCwd), and nothing in it reaches the owner's turns. */
|
|
777
|
+
function cloudGuestOpened(threadId) {
|
|
778
|
+
return Boolean(CLOUD_HOME) && cloudThreadStarters(threadId).some((person) => person !== undefined && !cloudOwnerPerson(person));
|
|
779
|
+
}
|
|
780
|
+
/** On a Cloud home: turns running now that a guest drives through another
|
|
781
|
+
* conversation (a teammate's handoff started from a guest's turn), by
|
|
782
|
+
* thread, with their generation. They are guest-driven like the guest's
|
|
783
|
+
* own conversation while they run (cloudGuestDriven). */
|
|
784
|
+
const guestDrivenTurns = new Map();
|
|
785
|
+
/** On a Cloud home: whether a handoff's chain started in (or passed
|
|
786
|
+
* through) a guest-driven conversation: work a guest's turn hands a
|
|
787
|
+
* teammate is the guest's too, however deep. */
|
|
788
|
+
function cloudGuestDrivenHandoff(nodeId) {
|
|
789
|
+
if (!CLOUD_HOME || !nodeId)
|
|
790
|
+
return false;
|
|
791
|
+
let node = roomHandoffs.nodes.get(nodeId);
|
|
792
|
+
for (let steps = 0; node && steps < 32; steps++) {
|
|
793
|
+
// Every conversation the work came through, up to the one it started in.
|
|
794
|
+
if (node.id !== nodeId && cloudGuestDriven(node.threadId))
|
|
795
|
+
return true;
|
|
796
|
+
if (node.id === node.rootId)
|
|
797
|
+
break;
|
|
798
|
+
node = roomHandoffs.nodes.get(node.parentId ?? node.rootId);
|
|
799
|
+
}
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
802
|
+
/** Who a conversation a bot opens from another is for: on a Cloud home,
|
|
803
|
+
* never the owner when a guest drives the source (it opens as the guest's,
|
|
804
|
+
* or nobody's), so it is confined like the source. */
|
|
805
|
+
function openerFrom(fromThreadId) {
|
|
806
|
+
const person = threadPersonKey(fromThreadId);
|
|
807
|
+
if (!CLOUD_HOME || !cloudGuestDriven(fromThreadId))
|
|
808
|
+
return person;
|
|
809
|
+
return person && !cloudOwnerPerson(person) ? person : CLOUD_NOBODY_KEY;
|
|
810
|
+
}
|
|
811
|
+
/** The model a thread a bot opens from one of its own threads starts on:
|
|
812
|
+
* that thread's, not the bot default the person may have moved away from
|
|
813
|
+
* (#1950). Only the bot's own thread counts — a room or a teammate's thread
|
|
814
|
+
* is not one of its tasks, and another bot's model belongs to another engine.
|
|
815
|
+
* Approval stays the bot's, except that a level the inherited engine would
|
|
816
|
+
* have to confirm on a model switch starts the new thread in Ask. */
|
|
817
|
+
function parentThreadModel(botId, parentThreadId) {
|
|
818
|
+
// The stored profile, never a per-thread projection: the comparison is
|
|
819
|
+
// against the defaults the new thread would otherwise get.
|
|
820
|
+
const bot = store.bot(botId);
|
|
821
|
+
const modelSelection = bot && store.taskByThread(bot.id, parentThreadId)?.modelSelection;
|
|
822
|
+
if (!bot || !modelSelection)
|
|
823
|
+
return {};
|
|
824
|
+
const needsAsk = modelSwitchNeedsAsk(approvalModeFor(bot), registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, registry.cliTarget(modelSelection.instanceId)?.driverKind);
|
|
825
|
+
return { modelSelection, ...(needsAsk ? { approvalMode: "ask" } : {}) };
|
|
826
|
+
}
|
|
827
|
+
/** On a Cloud home: the conversation that work a guest-driven turn hands a
|
|
828
|
+
* teammate (delegate_bot, ask_bot) runs in. It is a fresh one of the guest's
|
|
829
|
+
* own on the teammate, never the owner's conversation with it, so the work,
|
|
830
|
+
* and anything that later continues there, stays confined. Undefined when
|
|
831
|
+
* the source is not guest-driven, null when it could not be made. */
|
|
832
|
+
function guestWorkThread(from, target, fromThreadId, brief) {
|
|
833
|
+
if (!CLOUD_HOME || !cloudGuestDriven(fromThreadId))
|
|
834
|
+
return undefined;
|
|
835
|
+
const title = brief.replace(/\s+/g, " ").trim().slice(0, 60) || `From ${from.name}`;
|
|
836
|
+
const task = store.createTask(target.id, title, false, undefined, { botId: from.id, name: from.name, at: Date.now() });
|
|
837
|
+
if (!task)
|
|
838
|
+
return null;
|
|
839
|
+
threadStarters.set(task.threadId, openerFrom(fromThreadId));
|
|
840
|
+
return task.threadId;
|
|
841
|
+
}
|
|
842
|
+
/** On a personal Cloud home the only turns confined like a guest's
|
|
843
|
+
* (cloudGuestDriven) are in what came before it: a routine nobody is proven
|
|
844
|
+
* to have written, or a conversation or room a revoked session opened. What
|
|
845
|
+
* the owner is told about one, and when its engine cannot be confined. */
|
|
846
|
+
const ROUTINE_FROM_BEFORE = "This routine was made before this update. Open it and save it once to run it with full access.";
|
|
847
|
+
const CONVERSATION_FROM_BEFORE = "This conversation is from before your Cloud was only yours, so this bot works in it without a shell. Start a new conversation to use it fully.";
|
|
848
|
+
function confinedWhy(threadId) {
|
|
849
|
+
const run = activeRoutineRunForThread(threadId);
|
|
850
|
+
const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
|
|
851
|
+
return run && writer && !cloudOwnerPerson(writer) ? ROUTINE_FROM_BEFORE : CONVERSATION_FROM_BEFORE;
|
|
852
|
+
}
|
|
853
|
+
function guestEngineRefusal(threadId) {
|
|
854
|
+
const why = confinedWhy(threadId);
|
|
855
|
+
return why === ROUTINE_FROM_BEFORE ? `This bot's engine can't run it that way. ${why}`
|
|
856
|
+
: "This conversation is from before your Cloud was only yours, and this bot's engine can't work in it. Start a new conversation.";
|
|
857
|
+
}
|
|
858
|
+
/** On a Cloud home: a turn a guest drives, which runs in Ask whatever the
|
|
859
|
+
* bot's own level (no Full access, no Auto reviewer, no saved commands), with
|
|
860
|
+
* no shell (guestEngineRefusal): a conversation a guest opened, a run of a
|
|
861
|
+
* routine a guest wrote, work a guest's turn handed on (guestDrivenTurns), or
|
|
862
|
+
* a room whose latest line from a person is a guest's. */
|
|
863
|
+
function cloudGuestDriven(threadId) {
|
|
864
|
+
if (!CLOUD_HOME)
|
|
865
|
+
return false;
|
|
866
|
+
if (guestDrivenTurns.has(threadId) || cloudGuestOpened(threadId))
|
|
867
|
+
return true;
|
|
868
|
+
// A run of a routine a guest wrote (or last edited) is the guest's.
|
|
869
|
+
const run = activeRoutineRunForThread(threadId);
|
|
870
|
+
const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
|
|
871
|
+
if (writer && !cloudOwnerPerson(writer))
|
|
872
|
+
return true;
|
|
873
|
+
if (!store.groupByThread(threadId))
|
|
874
|
+
return false;
|
|
875
|
+
const person = store.messagesFor(threadId).findLast((line) => line.role === "user" && !line.peerAsk && line.sender?.id)?.sender?.id;
|
|
876
|
+
return person !== undefined && !cloudOwnerPerson(person);
|
|
877
|
+
}
|
|
514
878
|
/** On a Cloud home: a request from one of the owner's own devices. The
|
|
515
879
|
* Admin's signed pairing gives the desktop an admin session; a guest the
|
|
516
880
|
* owner pairs with chat-only access has client scope. Loopback is not the
|
|
@@ -519,10 +883,31 @@ function sessionLendingPrincipal(auth) {
|
|
|
519
883
|
function cloudOwnerSession(auth) {
|
|
520
884
|
return auth.kind === "session" && auth.scopes.includes("admin");
|
|
521
885
|
}
|
|
522
|
-
/** On a Cloud home: whether a
|
|
523
|
-
*
|
|
886
|
+
/** On a Cloud home: whether a person key is the owner's, for who opened a
|
|
887
|
+
* conversation, who drives a turn, its approval level and its folder: the
|
|
888
|
+
* owner's one key, a device of theirs still paired, or a key from before
|
|
889
|
+
* they shared one (cloud-owner.ts, adopted). */
|
|
524
890
|
function cloudOwnerPerson(person) {
|
|
525
|
-
|
|
891
|
+
if (!person)
|
|
892
|
+
return false;
|
|
893
|
+
if (person === CLOUD_OWNER_KEY || cloudEarlierOwners.adopted.has(person))
|
|
894
|
+
return true;
|
|
895
|
+
return cloudOwnerDevice(person);
|
|
896
|
+
}
|
|
897
|
+
/** …and whether it is proven the owner's, the only kind whose words may
|
|
898
|
+
* reach the lent Mac, memory, recall and the recent-work brief: a key from
|
|
899
|
+
* before counts only with proof it was the owner's (cloud-owner.ts, proven),
|
|
900
|
+
* never just because nobody revoked it. */
|
|
901
|
+
function cloudProvenOwnerPerson(person) {
|
|
902
|
+
if (!person)
|
|
903
|
+
return false;
|
|
904
|
+
if (person === CLOUD_OWNER_KEY || cloudEarlierOwners.proven.has(person))
|
|
905
|
+
return true;
|
|
906
|
+
return cloudOwnerDevice(person);
|
|
907
|
+
}
|
|
908
|
+
/** Written before the owner had one key: a device of theirs still paired. */
|
|
909
|
+
function cloudOwnerDevice(person) {
|
|
910
|
+
return sessions.list().some((session) => session.scopes.includes("admin") && personKey(session) === person);
|
|
526
911
|
}
|
|
527
912
|
/** On a Cloud home, a session that is not one of the owner's own devices may
|
|
528
913
|
* not answer a card of any kind. */
|
|
@@ -594,7 +979,7 @@ function cardAnswererFor(auth) {
|
|
|
594
979
|
if (auth.kind === "loopback")
|
|
595
980
|
return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
|
|
596
981
|
return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user",
|
|
597
|
-
...(CLOUD_HOME ? { person:
|
|
982
|
+
...(CLOUD_HOME ? { person: actorKey(auth) } : {}) };
|
|
598
983
|
}
|
|
599
984
|
/** On a Cloud home, who is answering a card right now, by request id: the
|
|
600
985
|
* answer reaches the turn before `answeredBy` is written, and a lending turn
|
|
@@ -616,7 +1001,7 @@ async function answeringCardAs(auth, threadId, requestId, work) {
|
|
|
616
1001
|
return Boolean(card && !card.answered && !card.dismissed && !card.expired);
|
|
617
1002
|
})();
|
|
618
1003
|
if (CLOUD_HOME && open)
|
|
619
|
-
cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ?
|
|
1004
|
+
cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? actorKey(auth) : undefined);
|
|
620
1005
|
try {
|
|
621
1006
|
await withDecisionActor(decisionActorFor(auth), work);
|
|
622
1007
|
}
|
|
@@ -743,6 +1128,11 @@ function settleRoomFloor(group) {
|
|
|
743
1128
|
return;
|
|
744
1129
|
store.patchGroup(group.id, { audienceFloor: next === "everyone" ? undefined : next });
|
|
745
1130
|
}
|
|
1131
|
+
/** On a Cloud home the recent-work brief draws only on conversations the
|
|
1132
|
+
* owner alone wrote in (see cloudOwnerOnlyThread). */
|
|
1133
|
+
function recentWorkFilter() {
|
|
1134
|
+
return CLOUD_HOME ? { include: cloudOwnerOnlyThread } : {};
|
|
1135
|
+
}
|
|
746
1136
|
/** The conversations a bot's recent-work brief may draw on: its own, and the
|
|
747
1137
|
* rooms roomFeedsBot allows. */
|
|
748
1138
|
function recentWorkSources(bot) {
|
|
@@ -757,7 +1147,7 @@ function recentWorkSources(bot) {
|
|
|
757
1147
|
* or a room must not be able to pull a private chat into its reply. The
|
|
758
1148
|
* conversations are the ones session_search would search, minus this one. */
|
|
759
1149
|
function autoRecallPrompt(bot, threadId, message, opts) {
|
|
760
|
-
if (!autoRecallEnabled(cfg))
|
|
1150
|
+
if (bot.memoryEnabled === false || !autoRecallEnabled(cfg))
|
|
761
1151
|
return "";
|
|
762
1152
|
const roomByThread = new Map();
|
|
763
1153
|
if (opts.conversations) {
|
|
@@ -770,7 +1160,10 @@ function autoRecallPrompt(bot, threadId, message, opts) {
|
|
|
770
1160
|
}
|
|
771
1161
|
}
|
|
772
1162
|
const threadIds = opts.conversations
|
|
773
|
-
? [...new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])]
|
|
1163
|
+
? [...new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])]
|
|
1164
|
+
// On a Cloud home a conversation anyone else wrote in never feeds
|
|
1165
|
+
// another: its words would reach the owner's turns (and a lent Mac).
|
|
1166
|
+
.filter((id) => id !== threadId && (!CLOUD_HOME || cloudOwnerOnlyThread(id)))
|
|
774
1167
|
: [];
|
|
775
1168
|
try {
|
|
776
1169
|
const recalled = buildRecall({
|
|
@@ -902,11 +1295,13 @@ function adminAuditPlan(method, path) {
|
|
|
902
1295
|
m = /^\/api\/groups\/([\w-]+)$/.exec(path);
|
|
903
1296
|
if (m && method === "PATCH")
|
|
904
1297
|
plan.roomId = m[1];
|
|
905
|
-
m = /^\/api\/instances\/([\w.-]+)\/(install|auth\/complete|auth\/sign-out|claude-update)$/.exec(path);
|
|
1298
|
+
m = /^\/api\/instances\/([\w.-]+)\/(install|auth\/complete|auth\/sign-out|claude-update|leftover-files\/remove)$/.exec(path);
|
|
906
1299
|
if (m && method === "POST")
|
|
907
1300
|
plan.engineAction = { id: m[1], action: m[2].replace("/", "-") };
|
|
908
1301
|
if (method === "POST" && path === "/api/instances/claude-accounts")
|
|
909
1302
|
plan.engineAction = { id: "claude", action: "account-add" };
|
|
1303
|
+
if (method === "POST" && path === "/api/instances/chatgpt-accounts")
|
|
1304
|
+
plan.engineAction = { id: "chatgpt", action: "account-add" };
|
|
910
1305
|
return plan.config || plan.botId || plan.createsBots || plan.deletesBot || plan.createsWebhook || plan.webhookId || plan.sessionId || plan.pairing || plan.engineAction || plan.roomId ? plan : null;
|
|
911
1306
|
}
|
|
912
1307
|
const WEBHOOK_AUDIT_FIELDS = ["name", "prompt", "botId", "runOn", "enabled", "eventTypes"];
|
|
@@ -1081,6 +1476,46 @@ function queuedTurnTrigger(item) {
|
|
|
1081
1476
|
return item.sender ? { kind: "user", label: item.sender.name } : { kind: "owner" };
|
|
1082
1477
|
}
|
|
1083
1478
|
const providerAuthSessions = new ProviderAuthSessions();
|
|
1479
|
+
/** OAuth sign-in for URL servers; tokens live in their own owner-only file. */
|
|
1480
|
+
const mcpOAuth = new McpOAuthManager({
|
|
1481
|
+
file: join(DATA_DIR, "mcp-oauth.json"),
|
|
1482
|
+
isOwnerLive: (owner) => owner === "loopback" || sessions.isLive(owner),
|
|
1483
|
+
// Read the registered app when needed; never copy its secret into the token store.
|
|
1484
|
+
clients: (name, url) => {
|
|
1485
|
+
const raw = cfg.mcpServers?.[name];
|
|
1486
|
+
const parsed = raw === undefined ? null : parseStoredMcpServer(name, raw);
|
|
1487
|
+
return parsed?.ok && isRemoteMcpServer(parsed.server) && parsed.server.url === url ? parsed.server.oauth : undefined;
|
|
1488
|
+
},
|
|
1489
|
+
});
|
|
1490
|
+
// OpenCode reads provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, …) from
|
|
1491
|
+
// its environment, as it does in a terminal, but only where that environment
|
|
1492
|
+
// is the person's own shell (openCodeProviderKeysAllowed). Wired before the
|
|
1493
|
+
// first catalog probe below. Company enrollment arrives later from the
|
|
1494
|
+
// desktop parent and is wired in once it can (openCodeOrganisationManaged).
|
|
1495
|
+
let openCodeOrganisationManaged = () => false;
|
|
1496
|
+
const openCodeKeysAllowed = () => openCodeProviderKeysAllowed({
|
|
1497
|
+
cloudHome: Boolean(CLOUD_HOME),
|
|
1498
|
+
hostedWorkspace: HOSTED_WORKSPACE,
|
|
1499
|
+
organisationManaged: openCodeOrganisationManaged(),
|
|
1500
|
+
sharedSignIn: sharedSignIn(signInAllowList()),
|
|
1501
|
+
});
|
|
1502
|
+
setOpenCodeProviderKeyPolicy(openCodeKeysAllowed);
|
|
1503
|
+
let openCodeKeysLastAllowed = openCodeKeysAllowed();
|
|
1504
|
+
/** OpenCode lists a provider's models only while it may read that
|
|
1505
|
+
* provider's key. When enrollment or the sign-in list changes the answer,
|
|
1506
|
+
* list them again, so the picker never offers models its turns lack. */
|
|
1507
|
+
function resyncOpenCodeProviderKeys() {
|
|
1508
|
+
const allowed = openCodeKeysAllowed();
|
|
1509
|
+
if (allowed === openCodeKeysLastAllowed)
|
|
1510
|
+
return;
|
|
1511
|
+
openCodeKeysLastAllowed = allowed;
|
|
1512
|
+
for (const instance of registry.instances()) {
|
|
1513
|
+
if (instance.driverKind !== "opencodeGo")
|
|
1514
|
+
continue;
|
|
1515
|
+
void registry.refreshModels(instance.instanceId)
|
|
1516
|
+
.then(() => broadcast({ kind: "config", ...configStatus() }), () => { });
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1084
1519
|
await registry.load(providerConfigs(), decorateHostedProvider);
|
|
1085
1520
|
const bundledSkills = loadBundledSkills();
|
|
1086
1521
|
const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
|
|
@@ -1179,7 +1614,10 @@ let companyRuntimeReady;
|
|
|
1179
1614
|
const companyRuntimeStarted = new Promise(resolve => { companyRuntimeReady = resolve; });
|
|
1180
1615
|
/** The enrolled organisation's desktop policy: in memory only, read-only, and
|
|
1181
1616
|
* inert (null) unless Electron's enrolled parent sends one. */
|
|
1182
|
-
const managedPolicy = new ManagedDesktopPolicy({ onChange: () =>
|
|
1617
|
+
const managedPolicy = new ManagedDesktopPolicy({ onChange: () => {
|
|
1618
|
+
broadcast({ kind: "config", ...configStatus() });
|
|
1619
|
+
resyncOpenCodeProviderKeys();
|
|
1620
|
+
} });
|
|
1183
1621
|
/** A computer kind this server will not use, refused before anything is
|
|
1184
1622
|
* prepared: a Cloud home never offers this computer or a Local VM
|
|
1185
1623
|
* (cloud-home.ts), and an enrolled organisation may disallow any kind. */
|
|
@@ -1203,7 +1641,10 @@ const managedDesktop = new ManagedDesktopProviders({
|
|
|
1203
1641
|
beforeReplace: stopCompanyInstances,
|
|
1204
1642
|
// Ids became stable across re-enrolment; carry old references forward once.
|
|
1205
1643
|
migrate: aliases => { store.renameInstances(new Map(aliases.map(({ from, to }) => [from, to]))); },
|
|
1206
|
-
onAvailability: () =>
|
|
1644
|
+
onAvailability: () => {
|
|
1645
|
+
broadcast({ kind: "config", ...configStatus() });
|
|
1646
|
+
resyncOpenCodeProviderKeys();
|
|
1647
|
+
},
|
|
1207
1648
|
afterReplace: ids => {
|
|
1208
1649
|
for (const id of providerInstancesChanging)
|
|
1209
1650
|
if (managedDesktop.owns(id))
|
|
@@ -1212,8 +1653,11 @@ const managedDesktop = new ManagedDesktopProviders({
|
|
|
1212
1653
|
// Existing renderer config events refresh /api/instances as well, so
|
|
1213
1654
|
// Company grants and revocations appear without reloading the window.
|
|
1214
1655
|
broadcast({ kind: "config", ...configStatus() });
|
|
1656
|
+
resyncOpenCodeProviderKeys();
|
|
1215
1657
|
},
|
|
1216
1658
|
});
|
|
1659
|
+
// An organisation-managed desktop's environment is not the person's shell.
|
|
1660
|
+
openCodeOrganisationManaged = () => managedPolicy.current() !== null || managedDesktop.enrolled();
|
|
1217
1661
|
utilityParentPort?.on("message", event => {
|
|
1218
1662
|
const message = event.data;
|
|
1219
1663
|
if (message?.type !== "openmausbot:managed-desktop-identity")
|
|
@@ -1336,7 +1780,27 @@ function mintInternalCapability(capability) {
|
|
|
1336
1780
|
});
|
|
1337
1781
|
return token;
|
|
1338
1782
|
}
|
|
1783
|
+
/** A turn that was not provably the owner's has ended: judge its bot's
|
|
1784
|
+
* memory now, so a change it made is caught and an unchanged one is cleared
|
|
1785
|
+
* (server/lending-memory.ts). `generation` undefined: every such turn on the
|
|
1786
|
+
* thread (a teardown that settles threads, not generations). */
|
|
1787
|
+
function endForeignTurns(threadId, generation) {
|
|
1788
|
+
for (const [id, foreign] of foreignTurns) {
|
|
1789
|
+
if (foreign.threadId !== threadId || (generation !== undefined && id !== generation))
|
|
1790
|
+
continue;
|
|
1791
|
+
foreignTurns.delete(id);
|
|
1792
|
+
try {
|
|
1793
|
+
lendingMemory?.reconcile(foreign.botId, foreignTurnRunning(foreign.botId));
|
|
1794
|
+
}
|
|
1795
|
+
catch (error) {
|
|
1796
|
+
console.warn(`lending: could not check a bot's memory: ${error.message}`);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1339
1800
|
function revokeInternalCapabilityGeneration(threadId, generation) {
|
|
1801
|
+
endForeignTurns(threadId, generation);
|
|
1802
|
+
if (guestDrivenTurns.get(threadId) === generation)
|
|
1803
|
+
guestDrivenTurns.delete(threadId);
|
|
1340
1804
|
for (const [token, capability] of internalCapabilities) {
|
|
1341
1805
|
if (capability.threadId === threadId && capability.generation === generation) {
|
|
1342
1806
|
internalCapabilities.delete(token);
|
|
@@ -1363,6 +1827,9 @@ function revokeInternalCapabilitiesForThread(threadId) {
|
|
|
1363
1827
|
function revokeAllInternalCapabilities() {
|
|
1364
1828
|
computerSelectionTurns.clear();
|
|
1365
1829
|
internalCapabilities.clear();
|
|
1830
|
+
// foreignTurns stays: a turn that is not provably the owner's can still
|
|
1831
|
+
// write while its engine is torn down, and its own end (which revokes its
|
|
1832
|
+
// generation by id) is what judges it (server/lending-memory.ts).
|
|
1366
1833
|
activeInternalGenerationByThread.clear();
|
|
1367
1834
|
internalGenerationByProviderTurn.clear();
|
|
1368
1835
|
}
|
|
@@ -1474,6 +1941,7 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
|
|
|
1474
1941
|
OMB_ROOM_TURN: roomCoordination ? "1" : "0",
|
|
1475
1942
|
OMB_OWN_THREAD_CREATION: ownThreadCreation ? "1" : "0",
|
|
1476
1943
|
OMB_SKILL_AUTHORING_ENABLED: skillAuthoring ? "1" : "0",
|
|
1944
|
+
OMB_MEMORY_ENABLED: store.bot(botId)?.memoryEnabled === false ? "0" : "1",
|
|
1477
1945
|
// The shared-computer tools are advertised only while the workspace
|
|
1478
1946
|
// gate is on; the routes behind them refuse regardless.
|
|
1479
1947
|
OMB_SHARED_COMPUTERS_ENABLED: lendingEnabled() ? "1" : "0",
|
|
@@ -1902,7 +2370,15 @@ function canAdmitDirectTurn(botId, threadId) {
|
|
|
1902
2370
|
/** Routine and webhook dispatch shares startTurn's admission preconditions
|
|
1903
2371
|
* instead of waiting for whole-bot idleness: a free thread slot and no
|
|
1904
2372
|
* active group turn. A group turn blocks scheduled starts the same way it
|
|
1905
|
-
* blocks every other turn kind; it does not consume a capacity slot.
|
|
2373
|
+
* blocks every other turn kind; it does not consume a capacity slot.
|
|
2374
|
+
*
|
|
2375
|
+
* A fresh dispatch also pins to the bot's own project folder, exactly like
|
|
2376
|
+
* startTurn's cwd resolution: a free thread slot alone can miss a second
|
|
2377
|
+
* task of this bot landing in that one folder (#F-collide). Predicting that
|
|
2378
|
+
* collision here lets it defer through the same busy-target machinery as
|
|
2379
|
+
* capacity and group-turn contention, instead of reaching startTurn's own
|
|
2380
|
+
* workspace claim — which throws deep inside a detached dispatch that no
|
|
2381
|
+
* caller here awaits, so the run would otherwise fail permanently. */
|
|
1906
2382
|
function unattendedDispatchState(botId) {
|
|
1907
2383
|
const bot = store.bot(botId);
|
|
1908
2384
|
const decision = admit("unattended", {}, {
|
|
@@ -1910,7 +2386,19 @@ function unattendedDispatchState(botId) {
|
|
|
1910
2386
|
atCapacity: Boolean(bot) && botAtThreadCapacity(botId),
|
|
1911
2387
|
groupTurn: Boolean(bot) && Boolean(activeGroupTurnForBot(botId)),
|
|
1912
2388
|
});
|
|
1913
|
-
|
|
2389
|
+
if (decision.action === "refuse")
|
|
2390
|
+
return decision.code === "missing" ? "missing" : "busy";
|
|
2391
|
+
if (bot?.cwd) {
|
|
2392
|
+
try {
|
|
2393
|
+
if (turnResources.blocker(workspaceResource(bot.cwd), { threadId: "", generation: "" }))
|
|
2394
|
+
return "busy";
|
|
2395
|
+
}
|
|
2396
|
+
catch {
|
|
2397
|
+
// An unresolvable folder is startTurn's own admission check to
|
|
2398
|
+
// report; this prediction only ever adds a defer, never a refusal.
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
return "ready";
|
|
1914
2402
|
}
|
|
1915
2403
|
function requestedTaskBot(botId, rawThreadId) {
|
|
1916
2404
|
const profile = store.bot(botId);
|
|
@@ -2076,7 +2564,16 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
|
|
|
2076
2564
|
/** The bot's browser for this turn: agent-browser, one isolated session per
|
|
2077
2565
|
* browser profile or per bot (docs/plans/browser-engine.md). Null, with the
|
|
2078
2566
|
* reason logged once, when the engine is not on this machine. */
|
|
2079
|
-
const browserRuntime = new BrowserRuntime(
|
|
2567
|
+
const browserRuntime = new BrowserRuntime({
|
|
2568
|
+
// The bot's restart_browser tool. An open Browser panel watched the old
|
|
2569
|
+
// browser; drop its stream the way the panel's own Restart button does.
|
|
2570
|
+
closeBrowser: async (session, spec) => {
|
|
2571
|
+
const closed = await closeBrowserSession(spec.command, browserRuntimeEnv({ ...spec.env, AGENT_BROWSER_SESSION: session }));
|
|
2572
|
+
if (closed)
|
|
2573
|
+
browserLive.closeForSession(session);
|
|
2574
|
+
return closed;
|
|
2575
|
+
},
|
|
2576
|
+
});
|
|
2080
2577
|
const browserLive = new BrowserLive({ runtime: browserRuntime });
|
|
2081
2578
|
// Temporary profiles last for this server run, but are never saved to disk.
|
|
2082
2579
|
// The viewer and the agent must address the SAME temporary browser.
|
|
@@ -2763,7 +3260,7 @@ function previewSystemPrompt(bot) {
|
|
|
2763
3260
|
{ id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
|
|
2764
3261
|
{ id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
|
|
2765
3262
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
2766
|
-
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace) }) },
|
|
3263
|
+
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
|
|
2767
3264
|
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
|
|
2768
3265
|
]);
|
|
2769
3266
|
const totalBytes = built.sections.reduce((n, s) => n + s.bytes, 0);
|
|
@@ -2851,7 +3348,12 @@ async function botOverview(bot) {
|
|
|
2851
3348
|
* with one deliberate exception: a Chief of Staff with Full access makes the
|
|
2852
3349
|
* threads it delegates Full too (delegatedFullAccess), so the grant the
|
|
2853
3350
|
* person gave the Chief covers the work the Chief hands out. */
|
|
2854
|
-
const approvalModeForTurn = (bot, peerInitiated = false) => {
|
|
3351
|
+
const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
|
|
3352
|
+
// On a Cloud home a turn a guest drives runs in Ask, whatever the bot's
|
|
3353
|
+
// own level. Judged by the conversation the turn runs in (a room's, for a
|
|
3354
|
+
// room turn), never by whichever of the bot's conversations is active.
|
|
3355
|
+
if (cloudGuestDriven(threadId))
|
|
3356
|
+
return "ask";
|
|
2855
3357
|
const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
|
|
2856
3358
|
if (!supportsApprovalMode(bot.modelSelection, mode)) {
|
|
2857
3359
|
return "ask";
|
|
@@ -2866,7 +3368,7 @@ function fullAccessForSource(botId, threadId) {
|
|
|
2866
3368
|
return false;
|
|
2867
3369
|
const bot = store.projectBotForTask(botId, threadId) ?? owner.bot;
|
|
2868
3370
|
// Origin changes Custom to Auto, never Full; no live-turn state is needed.
|
|
2869
|
-
return approvalModeForTurn(bot) === "full";
|
|
3371
|
+
return approvalModeForTurn(bot, false, threadId) === "full";
|
|
2870
3372
|
}
|
|
2871
3373
|
function peerReviewRequired(bot, threadId) {
|
|
2872
3374
|
return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
|
|
@@ -2890,8 +3392,7 @@ function delegatedFullAccess(from, fromThreadId, target) {
|
|
|
2890
3392
|
}
|
|
2891
3393
|
/** Make a delegated thread Full and say so in it once, so the level the
|
|
2892
3394
|
* chip shows and the level the turns run at agree, and the person can see
|
|
2893
|
-
* where the access came from.
|
|
2894
|
-
* across delegations and must not collect a chip per request. */
|
|
3395
|
+
* where the access came from. */
|
|
2895
3396
|
function grantDelegatedFullAccess(from, target, threadId) {
|
|
2896
3397
|
if (store.taskByThread(target.id, threadId)?.approvalMode === "full")
|
|
2897
3398
|
return;
|
|
@@ -2905,13 +3406,16 @@ function grantDelegatedFullAccess(from, target, threadId) {
|
|
|
2905
3406
|
/** A room member's level for one turn. Work a Full-access Chief hands out
|
|
2906
3407
|
* in a room runs Full for that turn: the room thread is shared, so the
|
|
2907
3408
|
* level is not stored on it — it rides the handoff. */
|
|
2908
|
-
function roomTurnApprovalMode(bot, orchestration) {
|
|
3409
|
+
function roomTurnApprovalMode(bot, threadId, orchestration) {
|
|
3410
|
+
// On a Cloud home a room turn a guest drives runs in Ask (cloudGuestDriven).
|
|
3411
|
+
if (cloudGuestDriven(threadId))
|
|
3412
|
+
return "ask";
|
|
2909
3413
|
const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
|
|
2910
3414
|
const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
|
|
2911
3415
|
const from = source ? store.bot(source.botId) : undefined;
|
|
2912
3416
|
if (from && source && delegatedFullAccess(from, source.threadId, bot))
|
|
2913
3417
|
return "full";
|
|
2914
|
-
return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId));
|
|
3418
|
+
return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
|
|
2915
3419
|
}
|
|
2916
3420
|
/** Privileged approval-mode transitions are deliberately absent from the
|
|
2917
3421
|
* loopback HTTP authority model: a bot with shell access can curl that
|
|
@@ -2963,6 +3467,9 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
2963
3467
|
if (bot.approvalGrant.allThreads && mode === "full") {
|
|
2964
3468
|
store.setAllThreadApprovalMode(botId, mode);
|
|
2965
3469
|
}
|
|
3470
|
+
else if (bot.approvalGrant.refreshPermissions && bot.approvalGrant.threadId) {
|
|
3471
|
+
store.refreshTaskPermissions(botId, bot.approvalGrant.threadId);
|
|
3472
|
+
}
|
|
2966
3473
|
else if (bot.approvalGrant.threadId) {
|
|
2967
3474
|
store.patchTask(botId, bot.approvalGrant.threadId, { approvalMode: mode, autoApprove: false });
|
|
2968
3475
|
}
|
|
@@ -3168,6 +3675,39 @@ function handleDesktopTrustedApprovalMessage(raw) {
|
|
|
3168
3675
|
respond({ ok: false, error: "Invalid thread approval scope" });
|
|
3169
3676
|
return true;
|
|
3170
3677
|
}
|
|
3678
|
+
if (message.refreshPermissions !== undefined && message.refreshPermissions !== true) {
|
|
3679
|
+
respond({ ok: false, error: "Invalid permission refresh" });
|
|
3680
|
+
return true;
|
|
3681
|
+
}
|
|
3682
|
+
if (message.refreshPermissions === true) {
|
|
3683
|
+
const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
|
|
3684
|
+
const savedMode = approvalModeFor({ ...existing, approvalGrant: undefined });
|
|
3685
|
+
if (message.threadOnly !== true || !target || message.allThreads === true || message.modelSelection !== undefined || message.updateBotDefault !== undefined || savedMode !== mode) {
|
|
3686
|
+
respond({ ok: false, error: "Choose this bot's approval level in bot settings before refreshing a thread" });
|
|
3687
|
+
return true;
|
|
3688
|
+
}
|
|
3689
|
+
if (existing.approvalGrant || threadBusy(botId, target.threadId)) {
|
|
3690
|
+
respond({ ok: false, error: "Stop this thread and finish its pending approval change first" });
|
|
3691
|
+
return true;
|
|
3692
|
+
}
|
|
3693
|
+
if (!supportsApprovalMode(target.modelSelection, mode)) {
|
|
3694
|
+
respond({ ok: false, error: "This thread's provider does not support that approval level" });
|
|
3695
|
+
return true;
|
|
3696
|
+
}
|
|
3697
|
+
if (mode === "auto" && target.computer === "local" && approvalModeFor(target) !== "auto" && message.acknowledgeLocalAuto !== true) {
|
|
3698
|
+
respond({ ok: false, error: "Auto mode on this computer requires confirming the warning" });
|
|
3699
|
+
return true;
|
|
3700
|
+
}
|
|
3701
|
+
if (mode === "full" || mode === "custom") {
|
|
3702
|
+
store.patchBot(botId, { approvalGrant: { requestId, mode, phase: "prepared", threadId: target.threadId, threadOnly: true, refreshPermissions: true } });
|
|
3703
|
+
}
|
|
3704
|
+
else if (!store.refreshTaskPermissions(botId, target.threadId)) {
|
|
3705
|
+
respond({ ok: false, error: "That thread is no longer available" });
|
|
3706
|
+
return true;
|
|
3707
|
+
}
|
|
3708
|
+
respond({ ok: true, bot: wireTrustedApprovalBot(store.bot(botId)) });
|
|
3709
|
+
return true;
|
|
3710
|
+
}
|
|
3171
3711
|
if (message.threadOnly === true) {
|
|
3172
3712
|
const target = typeof threadId === "string" ? store.projectBotForTask(botId, threadId) : null;
|
|
3173
3713
|
if (!target || message.modelSelection !== undefined || message.updateBotDefault !== undefined) {
|
|
@@ -3668,6 +4208,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
3668
4208
|
: child.status === "completed" ? `${bot?.name ?? "Teammate"} replied${child.groupId ? ` · ${store.group(child.groupId)?.name ?? "Room"}` : ""}`
|
|
3669
4209
|
: `${bot?.name ?? "Teammate"} — ${child.status}: ${child.result.slice(0, 180)}`,
|
|
3670
4210
|
ok: child.status === "completed" && !problem,
|
|
4211
|
+
// The chip label stays short; the report itself rides along so a
|
|
4212
|
+
// phone can show what the teammate said without opening its thread.
|
|
4213
|
+
...(child.status === "completed" && !problem && child.result.trim()
|
|
4214
|
+
? { output: redactSecretsInText(child.result.trim()).slice(0, 2_000) } : {}),
|
|
3671
4215
|
},
|
|
3672
4216
|
...(child.groupId ? { comm: { groupId: child.groupId, threadId: child.threadId, withBotId: child.botId,
|
|
3673
4217
|
withName: bot?.name ?? "Teammate", withColor: bot?.color ?? "blue" } }
|
|
@@ -3681,14 +4225,9 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
|
|
|
3681
4225
|
if (source)
|
|
3682
4226
|
markTaskContextExternallyUpdated(source, parent.threadId);
|
|
3683
4227
|
}
|
|
3684
|
-
// A work thread
|
|
3685
|
-
//
|
|
3686
|
-
//
|
|
3687
|
-
// sidebar, never deleted, and open again the moment anyone speaks
|
|
3688
|
-
// there. A finished job tidies up after itself; a failed or withheld
|
|
3689
|
-
// one stays in the sidebar where the person can see it. The pair
|
|
3690
|
-
// conversation is the standing line between two bots and never
|
|
3691
|
-
// auto-closes.
|
|
4228
|
+
// A successful direct work thread closes after its result is reported:
|
|
4229
|
+
// folded out of the sidebar, never deleted, and reopened if addressed.
|
|
4230
|
+
// Failed or withheld work stays visible.
|
|
3692
4231
|
const childTask = store.taskByThread(child.botId, child.threadId);
|
|
3693
4232
|
if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
|
|
3694
4233
|
&& childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
|
|
@@ -4337,8 +4876,47 @@ function audienceChanged() {
|
|
|
4337
4876
|
}
|
|
4338
4877
|
}
|
|
4339
4878
|
}
|
|
4879
|
+
// Cloud boot can revoke sessions before routes are registered. Create the
|
|
4880
|
+
// viewer manager before installing any revocation callbacks.
|
|
4881
|
+
const desktopViewer = createDesktopViewer({
|
|
4882
|
+
target: (id) => {
|
|
4883
|
+
if (id.startsWith("vps/")) {
|
|
4884
|
+
const botId = id.slice(4);
|
|
4885
|
+
if (store.bot(botId)?.cloudBackend !== "vps")
|
|
4886
|
+
return;
|
|
4887
|
+
return { key: id, resolve: async () => {
|
|
4888
|
+
const connection = vps.vpsDesktopConnection(botId);
|
|
4889
|
+
if (!connection)
|
|
4890
|
+
throw Object.assign(new Error("VPS viewer is not open"), { status: 409 });
|
|
4891
|
+
return connection;
|
|
4892
|
+
} };
|
|
4893
|
+
}
|
|
4894
|
+
const targets = [SHARED_LOCAL_VM_TARGET, ...store.bots.map(bot => perBotLocalVmTarget(bot.id)),
|
|
4895
|
+
...Array.from({ length: localVmMaxInstances(cfg) }, (_, seat) => poolLocalVmTarget(seat))];
|
|
4896
|
+
const target = targets.find(target => viewerTargetId(target) === id);
|
|
4897
|
+
return target && localDesktopTarget(target, {
|
|
4898
|
+
// A viewer can repair a sick CUA driver; skip desktop health probes.
|
|
4899
|
+
status: target => containerComputerStatus(undefined, undefined, target, { probeDesktop: false }),
|
|
4900
|
+
touch: target => localVmIdleFor(target).touch(),
|
|
4901
|
+
});
|
|
4902
|
+
},
|
|
4903
|
+
live: auth => auth.kind === "loopback" || sessions.isLive(auth.session.id),
|
|
4904
|
+
// A phone paired with this server directly drives a bot's Local VM through
|
|
4905
|
+
// this proxy under its control lease (see the local-computer join route).
|
|
4906
|
+
// The lease must hold the bot's computer, and that computer must be the
|
|
4907
|
+
// desktop being viewed: a lease on one bot opens no other bot's VM.
|
|
4908
|
+
lease: (id, botId, controlLeaseId, threadId) => {
|
|
4909
|
+
const bot = store.bot(botId);
|
|
4910
|
+
if (!bot || viewerTargetId(localVmTargetForStatus(bot.id, threadId)) !== id)
|
|
4911
|
+
return;
|
|
4912
|
+
const key = botComputerControlKey(bot);
|
|
4913
|
+
const holds = () => computerControl.ownsLease(key, controlLeaseId);
|
|
4914
|
+
return holds() ? holds : undefined;
|
|
4915
|
+
},
|
|
4916
|
+
});
|
|
4340
4917
|
function closeSessionStreams(sessionId) {
|
|
4341
4918
|
browserLive.closeForOwner(sessionId);
|
|
4919
|
+
desktopViewer.closeForOwner(sessionId);
|
|
4342
4920
|
for (const client of sseClients) {
|
|
4343
4921
|
if (client.sessionId !== sessionId)
|
|
4344
4922
|
continue;
|
|
@@ -4353,8 +4931,35 @@ function closeSessionStreams(sessionId) {
|
|
|
4353
4931
|
}
|
|
4354
4932
|
sessions.onSessionRevoked((sessionId) => {
|
|
4355
4933
|
providerAuthSessions.revokeOwner(sessionId);
|
|
4934
|
+
mcpOAuth.revokeOwner(sessionId);
|
|
4356
4935
|
closeSessionStreams(sessionId);
|
|
4936
|
+
// A revoked owner device no longer vouches for its lines (cloudOwnerPerson).
|
|
4937
|
+
ownerOnlyCache.clear();
|
|
4357
4938
|
});
|
|
4939
|
+
/** On a Cloud home: a line someone else adds to a conversation while one of
|
|
4940
|
+
* the owner's turns runs in it (steered into it, or handed in) makes that
|
|
4941
|
+
* turn someone else's from then on. The lent Mac is refused at its next use
|
|
4942
|
+
* anyway (cloudLendingRefusal reads the whole conversation each time); this
|
|
4943
|
+
* makes whatever the bot writes to its memory from now on count as theirs
|
|
4944
|
+
* too (server/lending-memory.ts). */
|
|
4945
|
+
function noteSomeoneElsesLine(threadId) {
|
|
4946
|
+
const generation = activeInternalGenerationByThread.get(threadId);
|
|
4947
|
+
if (!generation || foreignTurns.has(generation))
|
|
4948
|
+
return;
|
|
4949
|
+
const bot = store.botByThread(threadId);
|
|
4950
|
+
if (bot && cloudHomeLendingRefusal(cloudLendingTurn({ botId: bot.id, threadId, generation })) !== null) {
|
|
4951
|
+
noteForeignTurn(bot.id, threadId, generation);
|
|
4952
|
+
}
|
|
4953
|
+
}
|
|
4954
|
+
if (lendingMemory) {
|
|
4955
|
+
store.onChange((change) => {
|
|
4956
|
+
if (change.type === "message" && change.message.role === "user")
|
|
4957
|
+
noteSomeoneElsesLine(change.threadId);
|
|
4958
|
+
// A deleted bot's record goes with it (and nothing recreates its workspace).
|
|
4959
|
+
else if (change.type === "bot.deleted")
|
|
4960
|
+
lendingMemory.forget(change.botId);
|
|
4961
|
+
});
|
|
4962
|
+
}
|
|
4358
4963
|
/** Every frame is numbered, and the last few hundred are kept, so a client
|
|
4359
4964
|
* whose connection dropped can ask for what it missed instead of
|
|
4360
4965
|
* re-downloading every transcript. The desktop reconnects in milliseconds
|
|
@@ -4456,7 +5061,7 @@ const askMessageByRequest = new Map(); // threadId:requestId -> messageId
|
|
|
4456
5061
|
const pendingCommandRules = new Map();
|
|
4457
5062
|
function commandRememberRefusal(auth, threadId, requestId, behavior) {
|
|
4458
5063
|
if (!canManageCommandAllowlist(auth))
|
|
4459
|
-
return "Only the
|
|
5064
|
+
return "Only the owner or an admin can save bot-wide command permissions.";
|
|
4460
5065
|
if (behavior !== "allow" || !pendingCommandRules.has(`${threadId}:${requestId}`)) {
|
|
4461
5066
|
return "This live request does not contain a command that can be saved. Allow it once instead.";
|
|
4462
5067
|
}
|
|
@@ -4643,10 +5248,9 @@ const watchdog = new TurnWatchdog({
|
|
|
4643
5248
|
onStall: (turn) => {
|
|
4644
5249
|
const stalledResourceOwner = turnResourceOwners.get(turn.threadId);
|
|
4645
5250
|
const stalledGeneration = directTurnGenerationByThread.get(turn.threadId);
|
|
5251
|
+
const stalledProviderTurn = directRequestOwners.get(turn.threadId);
|
|
4646
5252
|
cancelDirectTurnDispatch(turn.botId, turn.threadId);
|
|
4647
|
-
|
|
4648
|
-
// to the room grace cleanup added here.
|
|
4649
|
-
const stalledVmTarget = groupSpeakers.has(turn.threadId) ? localVmThreadTargets.get(turn.threadId) : undefined;
|
|
5253
|
+
const stalledVmTarget = localVmThreadTargets.get(turn.threadId);
|
|
4650
5254
|
revokeInternalCapabilitiesForThread(turn.threadId);
|
|
4651
5255
|
repeats.settle(turn.threadId);
|
|
4652
5256
|
const bot = botForThread(turn.botId, turn.threadId);
|
|
@@ -4684,6 +5288,9 @@ const watchdog = new TurnWatchdog({
|
|
|
4684
5288
|
return;
|
|
4685
5289
|
if (stalledResourceOwner && turnResourceOwners.get(turn.threadId)?.generation !== stalledResourceOwner.generation)
|
|
4686
5290
|
return;
|
|
5291
|
+
if (stalledGeneration && stalledProviderTurn?.generation === stalledGeneration && stalledProviderTurn.turnId) {
|
|
5292
|
+
retireProviderTurn(stalledProviderTurn.turnId);
|
|
5293
|
+
}
|
|
4687
5294
|
// A goal coordinator can stall before sendTurn reveals its provider
|
|
4688
5295
|
// turn id. Reusing the room during that ambiguous pre-id window would
|
|
4689
5296
|
// make old and replacement events indistinguishable. Keep ownership
|
|
@@ -4698,7 +5305,8 @@ const watchdog = new TurnWatchdog({
|
|
|
4698
5305
|
}
|
|
4699
5306
|
const group = store.groupByThread(turn.threadId);
|
|
4700
5307
|
const speaker = groupSpeakers.get(turn.threadId);
|
|
4701
|
-
|
|
5308
|
+
const stalledRoomSpeaker = group && group.busyBotId === turn.botId && speaker?.botId === turn.botId;
|
|
5309
|
+
if (stalledRoomSpeaker) {
|
|
4702
5310
|
groupSpeakers.delete(turn.threadId);
|
|
4703
5311
|
store.patchGroup(group.id, { busyBotId: null, unread: true });
|
|
4704
5312
|
}
|
|
@@ -4706,7 +5314,7 @@ const watchdog = new TurnWatchdog({
|
|
|
4706
5314
|
// computer claim. The generation checks above protect replacements.
|
|
4707
5315
|
releaseTurnResources(stalledResourceOwner);
|
|
4708
5316
|
const currentBot = store.bot(turn.botId);
|
|
4709
|
-
if (currentBot?.busy) {
|
|
5317
|
+
if (currentBot?.busy && (threadBusy(currentBot.id, turn.threadId) || stalledRoomSpeaker)) {
|
|
4710
5318
|
stopScreenPoller(currentBot.id, turn.threadId);
|
|
4711
5319
|
vpsThreadEnded(currentBot.id, turn.threadId);
|
|
4712
5320
|
if (store.taskByThread(currentBot.id, turn.threadId))
|
|
@@ -4827,10 +5435,34 @@ const memoryUpkeep = createMemoryUpkeep({
|
|
|
4827
5435
|
return facts.length;
|
|
4828
5436
|
},
|
|
4829
5437
|
sourceLabel: (botId, threadId) => memorySourceLabel({ task: store.taskByThread(botId, threadId), threadId }),
|
|
5438
|
+
// On a Cloud home upkeep only works on the owner's conversations (see the
|
|
5439
|
+
// capture hook below), so its writes are the owner's (lending-memory.ts).
|
|
5440
|
+
...(lendingMemory ? { writing: (botId, write) => lendingMemory.trustedWrite(botId, write) } : {}),
|
|
4830
5441
|
quietMs: () => captureQuietMs(cfg),
|
|
4831
5442
|
tidyHour: () => tidyHour(cfg),
|
|
4832
5443
|
log: (line) => console.log(line),
|
|
4833
5444
|
});
|
|
5445
|
+
// OMB Cloud home: the first bot turn that finishes here ticks the setup
|
|
5446
|
+
// checklist's "try something" step (firstCloudTurnPatch). The server writes
|
|
5447
|
+
// it, not an admin, so it is kept out of any admin's recorded changes.
|
|
5448
|
+
bus.subscribe((event) => {
|
|
5449
|
+
if (!CLOUD_HOME || event.type !== "turn.completed" || shouldIgnoreProviderEvent(event))
|
|
5450
|
+
return;
|
|
5451
|
+
const patch = firstCloudTurnPatch({
|
|
5452
|
+
cloudHome: true, recorded: cfg.onboarding?.firstTurnAt, ok: event.ok,
|
|
5453
|
+
known: Boolean(store.botByThread(event.threadId) ?? store.groupByThread(event.threadId)),
|
|
5454
|
+
});
|
|
5455
|
+
if (!patch)
|
|
5456
|
+
return;
|
|
5457
|
+
try {
|
|
5458
|
+
adminActorScope.exit(() => saveConfig(patch));
|
|
5459
|
+
Object.assign(cfg, loadConfig());
|
|
5460
|
+
broadcast({ kind: "config", ...configStatus() });
|
|
5461
|
+
}
|
|
5462
|
+
catch (error) {
|
|
5463
|
+
console.warn(`cloud home: could not record the first finished turn (${error instanceof Error ? error.message : "unknown"})`);
|
|
5464
|
+
}
|
|
5465
|
+
});
|
|
4834
5466
|
// A finished 1:1 turn of an upkeep bot waits for capture. Only the person's
|
|
4835
5467
|
// own conversation: not a room, not a turn another bot or the harness
|
|
4836
5468
|
// started (its "user" line is not the person), not a failed turn.
|
|
@@ -4844,6 +5476,10 @@ bus.subscribe((event) => {
|
|
|
4844
5476
|
if (!bot || !upkeepEnabled(bot) || store.groupByThread(event.threadId) || isInternalTurn(event.threadId)
|
|
4845
5477
|
|| isUnattended(bot.id, event.threadId) || routines?.runForThread(event.threadId))
|
|
4846
5478
|
return;
|
|
5479
|
+
// On a Cloud home the bot's memory reaches its owner's turns (and a lent
|
|
5480
|
+
// Mac): nothing is captured from a conversation anyone else wrote in.
|
|
5481
|
+
if (CLOUD_HOME && !cloudOwnerOnlyThread(event.threadId))
|
|
5482
|
+
return;
|
|
4847
5483
|
const path = store.activePath(event.threadId);
|
|
4848
5484
|
const replyAt = path.findLastIndex((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
|
|
4849
5485
|
if (replyAt < 0)
|
|
@@ -4876,7 +5512,7 @@ bus.subscribe((event) => {
|
|
|
4876
5512
|
const messages = store.messagesFor(event.threadId);
|
|
4877
5513
|
const reply = messages.findLast((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
|
|
4878
5514
|
const bot = store.botByThread(event.threadId) ?? (reply?.from ? store.bot(reply.from.botId) : undefined);
|
|
4879
|
-
if (!bot)
|
|
5515
|
+
if (!bot || bot.memoryEnabled === false)
|
|
4880
5516
|
return;
|
|
4881
5517
|
const tools = messages
|
|
4882
5518
|
.filter((message) => message.kind === "activity" && message.turnId === event.turnId && message.tool?.name)
|
|
@@ -4889,13 +5525,21 @@ bus.subscribe((event) => {
|
|
|
4889
5525
|
const room = store.groupByThread(event.threadId);
|
|
4890
5526
|
if (room && !roomFeedsBot(room, bot))
|
|
4891
5527
|
return;
|
|
4892
|
-
|
|
5528
|
+
// On a Cloud home the log feeds recall in the owner's turns: only the
|
|
5529
|
+
// owner's own conversations leave a line.
|
|
5530
|
+
if (CLOUD_HOME && (room || !cloudOwnerOnlyThread(event.threadId)))
|
|
5531
|
+
return;
|
|
5532
|
+
const writeLine = () => appendMemoryLog(bot.id, line, {
|
|
4893
5533
|
source: memorySourceLabel({
|
|
4894
5534
|
room,
|
|
4895
5535
|
task: store.taskByThread(bot.id, event.threadId),
|
|
4896
5536
|
threadId: event.threadId,
|
|
4897
5537
|
}),
|
|
4898
5538
|
});
|
|
5539
|
+
if (lendingMemory)
|
|
5540
|
+
lendingMemory.trustedWrite(bot.id, writeLine);
|
|
5541
|
+
else
|
|
5542
|
+
writeLine();
|
|
4899
5543
|
}
|
|
4900
5544
|
catch {
|
|
4901
5545
|
// a missing note never fails a turn
|
|
@@ -5180,13 +5824,18 @@ const boatLifecycleBusyBots = new Set();
|
|
|
5180
5824
|
// A refresh is a reader, not a lifecycle change. Keep its reservation until
|
|
5181
5825
|
// the provider settles even if the HTTP client leaves, and share it on retry.
|
|
5182
5826
|
const vpsPreviewRequests = new Map();
|
|
5827
|
+
// Joins in flight per bot. A second tab or a quick reconnect waits for the
|
|
5828
|
+
// pending join, then reuses its tunnel, instead of being refused by its lane.
|
|
5829
|
+
const vpsJoinRequests = new Map();
|
|
5183
5830
|
const orphanBoatLifecycleBusyIds = new Set();
|
|
5184
5831
|
const boatInventoryRequestsBusyIds = new Set();
|
|
5185
5832
|
const computerProviderConfigTransitions = new Set();
|
|
5186
5833
|
// A restore mutates and cleans a project work tree. Claim the bot across the
|
|
5187
5834
|
// entire async Git operation so a turn cannot start in that folder midway.
|
|
5188
5835
|
const checkpointRestoreLeases = new Set();
|
|
5189
|
-
|
|
5836
|
+
/** The effective idle window, read from config so a Settings change applies
|
|
5837
|
+
* to both new and already-armed timers (see the config route). */
|
|
5838
|
+
const localVmIdleMs = () => localVmIdleTimeoutMinutes(cfg) * 60_000;
|
|
5190
5839
|
/** How long a turn waits for Cua Driver after starting the container itself.
|
|
5191
5840
|
* A cold XFCE desktop needs some seconds; past this the turn reports the
|
|
5192
5841
|
* status it has rather than hanging on a container that will not come up. */
|
|
@@ -5317,7 +5966,7 @@ async function teamComputersPayload() {
|
|
|
5317
5966
|
* Assignment authorizes waking, never replacing a missing paid machine. */
|
|
5318
5967
|
async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
|
|
5319
5968
|
if (!canMount)
|
|
5320
|
-
throw new Error("This model
|
|
5969
|
+
throw new Error("This model cannot use the team's Boat computer; choose a model provider with computer tools or an explicit bot destination");
|
|
5321
5970
|
if (!boat.boatConfigured(cfg))
|
|
5322
5971
|
throw new Error("The team's Boat account is not configured; reconnect it in Settings");
|
|
5323
5972
|
const ownerId = teamComputerOwner(computer.id);
|
|
@@ -5353,7 +6002,7 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
|
|
|
5353
6002
|
// ACP engine" while already on one has nowhere to go.
|
|
5354
6003
|
throw new Error(providerSupportsLocal
|
|
5355
6004
|
? `local computer control is not available on ${process.platform} — select another destination`
|
|
5356
|
-
: "this model
|
|
6005
|
+
: "this model cannot control this computer — choose Claude or an ACP model provider, or select another destination");
|
|
5357
6006
|
}
|
|
5358
6007
|
const cua = readCuaConnection();
|
|
5359
6008
|
if (!cua) {
|
|
@@ -5626,7 +6275,7 @@ async function selectableComputers(bot) {
|
|
|
5626
6275
|
}
|
|
5627
6276
|
else if (surface === "browser") {
|
|
5628
6277
|
ready = caps?.browserMcp === true && builtInBrowserEnabled(cfg) && bot.browser !== false && browserEngineStatus().kind === "ready";
|
|
5629
|
-
reason = "The built-in browser is disabled, not installed, or unsupported by this model
|
|
6278
|
+
reason = "The built-in browser is disabled, not installed, or unsupported by this model.";
|
|
5630
6279
|
}
|
|
5631
6280
|
}
|
|
5632
6281
|
catch (error) {
|
|
@@ -5777,15 +6426,15 @@ function localVmIdleFor(target) {
|
|
|
5777
6426
|
let idle = localVmIdles.get(target.key);
|
|
5778
6427
|
if (idle)
|
|
5779
6428
|
return idle;
|
|
5780
|
-
idle = new LocalVmIdleTimer(
|
|
6429
|
+
idle = new LocalVmIdleTimer(localVmIdleMs(), () => localVmImageBusy || localVmLifecycleBusy.has(target.key) || localVmActiveThreads.has(target.key), async () => {
|
|
5781
6430
|
localVmLifecycleBusy.add(target.key);
|
|
5782
6431
|
try {
|
|
5783
6432
|
const status = await containerComputerStatus(undefined, undefined, target);
|
|
5784
|
-
// The
|
|
5785
|
-
//
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
6433
|
+
// The pinned VNC startup clears stale X locks. Keep the container's
|
|
6434
|
+
// home and installed software so inactivity is a reversible stop.
|
|
6435
|
+
if (status.container === "running" && status.managed) {
|
|
6436
|
+
const stopped = await containerComputerAction("stop", undefined, undefined, target);
|
|
6437
|
+
recordLocalVmIdleStop(target.key, stopped.stopped_at);
|
|
5789
6438
|
}
|
|
5790
6439
|
}
|
|
5791
6440
|
finally {
|
|
@@ -5827,7 +6476,7 @@ function releaseLocalVmThread(threadId) {
|
|
|
5827
6476
|
// Browser, This computer, Auto, or Off does not delete its old Local VM.
|
|
5828
6477
|
void (async () => {
|
|
5829
6478
|
if (localVmMode(cfg) === "pool") {
|
|
5830
|
-
// Same restore rule as per-bot: idle
|
|
6479
|
+
// Same restore rule as per-bot: idle shutdown preserves the container and
|
|
5831
6480
|
// its provisioned workspace, so every surviving seat stays Auto-eligible.
|
|
5832
6481
|
const seats = localVmMaxInstances(cfg);
|
|
5833
6482
|
for (let seat = 0; seat < seats; seat += 1) {
|
|
@@ -6118,10 +6767,13 @@ bus.subscribe((event) => {
|
|
|
6118
6767
|
// always reaches the human — even Full access never invents an answer.
|
|
6119
6768
|
const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
|
|
6120
6769
|
const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
|
|
6121
|
-
|
|
6770
|
+
// A turn a guest drives on a Cloud home is Ask, room turns included,
|
|
6771
|
+
// and no command the owner saved answers for it.
|
|
6772
|
+
const guestDriven = cloudGuestDriven(event.threadId);
|
|
6773
|
+
const effectiveApprovalMode = asker && !guestDriven ? approvalModeForTurn(asker, isInternalTurn(event.threadId), event.threadId) : "ask";
|
|
6122
6774
|
// Native shell descriptors are distinct from computer/MCP permissions;
|
|
6123
6775
|
// choosing a local desktop must not disable an exact shell grant.
|
|
6124
|
-
const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command
|
|
6776
|
+
const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command && !guestDriven
|
|
6125
6777
|
? commandAllowlistCandidate({ ...event.command, providerInstanceId: event.providerInstanceId ?? asker.modelSelection.instanceId })
|
|
6126
6778
|
: null;
|
|
6127
6779
|
const verdict = permission && asker && event.requestId
|
|
@@ -6226,7 +6878,8 @@ bus.subscribe((event) => {
|
|
|
6226
6878
|
: undefined,
|
|
6227
6879
|
commandAllowlist: command ?? undefined,
|
|
6228
6880
|
// Provider-owned session grants remain separate from exact commands.
|
|
6229
|
-
|
|
6881
|
+
// Never on a guest's turn: an "always" for it would outlive it.
|
|
6882
|
+
allowSession: permission && event.allowSession && !event.requiresExplicitApproval && !guestDriven ? true : undefined,
|
|
6230
6883
|
// The text stays for cards saved before heldCode existed, and for
|
|
6231
6884
|
// clients that do not know the key yet.
|
|
6232
6885
|
held: approvalHeldReason(heldContext),
|
|
@@ -6304,12 +6957,19 @@ bus.subscribe((event) => {
|
|
|
6304
6957
|
tool: { name: `retrying — attempt ${event.attempt + 1}/${RETRY_MAX_ATTEMPTS} in ${Math.round(event.delayMs / 1000)}s — ${event.reason}`, ok: true },
|
|
6305
6958
|
});
|
|
6306
6959
|
break;
|
|
6960
|
+
case "runtime.notice":
|
|
6961
|
+
// informational: the turn carries on. The prefix keeps it a status row
|
|
6962
|
+
// the person always sees (src/lib/activity-runs.ts), never a tool step
|
|
6963
|
+
// hidden with Tool calls off.
|
|
6964
|
+
pushMessage({ role: "bot", kind: "activity", tool: { name: `notice: ${event.message.slice(0, 240)}`, ok: true } });
|
|
6965
|
+
break;
|
|
6307
6966
|
case "runtime.error":
|
|
6308
6967
|
pushMessage({
|
|
6309
6968
|
role: "bot",
|
|
6310
6969
|
kind: "activity",
|
|
6311
6970
|
tool: {
|
|
6312
|
-
|
|
6971
|
+
// a setup error carries its fix, which must not be cut mid-command
|
|
6972
|
+
name: `error: ${event.message.slice(0, event.setup ? 320 : 160)}`,
|
|
6313
6973
|
ok: false,
|
|
6314
6974
|
setup: event.setup,
|
|
6315
6975
|
...(event.terminal ? { terminal: true } : {}),
|
|
@@ -7554,14 +8214,10 @@ async function finalScreenFrame(_botId, threadId) {
|
|
|
7554
8214
|
* (generateText — Haiku on Claude, the chat completion endpoint's text
|
|
7555
8215
|
* path on OpenAI-compatible engines). Null whenever that call cannot run,
|
|
7556
8216
|
* runs long, or answers with something that is not a plain short title;
|
|
7557
|
-
* the caller keeps the snippet it already applied.
|
|
7558
|
-
|
|
7559
|
-
|
|
7560
|
-
|
|
7561
|
-
"Reply with only a short title: 3 to 6 words, plain text, no quotes, no trailing period.",
|
|
7562
|
-
"Message:",
|
|
7563
|
-
text.trim().slice(0, 1_500),
|
|
7564
|
-
].join("\n");
|
|
8217
|
+
* the caller keeps the snippet it already applied. "conversation" names an
|
|
8218
|
+
* existing thread from titleConversationExcerpt (Regenerate title). */
|
|
8219
|
+
async function generateThreadTitle(provider, text, source = "first-message") {
|
|
8220
|
+
const prompt = threadTitlePrompt(text, source);
|
|
7565
8221
|
const expiry = new AbortController();
|
|
7566
8222
|
let timer;
|
|
7567
8223
|
try {
|
|
@@ -7585,6 +8241,8 @@ async function generateThreadTitle(provider, text) {
|
|
|
7585
8241
|
clearTimeout(timer);
|
|
7586
8242
|
}
|
|
7587
8243
|
}
|
|
8244
|
+
/** Threads whose Regenerate title one-shot is still running: one at a time. */
|
|
8245
|
+
const titleRegenerations = new Set();
|
|
7588
8246
|
// ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
|
|
7589
8247
|
async function startTurn(botId, text, opts) {
|
|
7590
8248
|
workspaceMaintenance.assertAvailable();
|
|
@@ -7671,6 +8329,15 @@ async function startTurn(botId, text, opts) {
|
|
|
7671
8329
|
const policyRefusal = policyModelRefusal(instance);
|
|
7672
8330
|
if (policyRefusal)
|
|
7673
8331
|
throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
|
|
8332
|
+
// On a Cloud home a guest's turn never gets a shell or reads outside its
|
|
8333
|
+
// own folder (docs/cloud-pro.md): an engine that cannot run it that way is
|
|
8334
|
+
// refused, in one plain line, before anything is recorded.
|
|
8335
|
+
// (The thread is idle here: a mark a turn that never started left is stale.)
|
|
8336
|
+
guestDrivenTurns.delete(threadId);
|
|
8337
|
+
const guestConfined = cloudGuestDriven(threadId) || cloudGuestDrivenHandoff(opts?.coordination?.id);
|
|
8338
|
+
if (guestConfined && instance.adapter.capabilities.guestTurns !== "confined") {
|
|
8339
|
+
throw Object.assign(new Error(guestEngineRefusal(threadId)), { status: 409, code: "guest_engine" });
|
|
8340
|
+
}
|
|
7674
8341
|
// Resolve only transport tags from this newly submitted text. The original
|
|
7675
8342
|
// string remains the durable message. Native-image providers get a
|
|
7676
8343
|
// path-free prompt and bounded inputs instead of needing a Read tool;
|
|
@@ -7691,11 +8358,9 @@ async function startTurn(botId, text, opts) {
|
|
|
7691
8358
|
const titled = store.titleTaskFromFirstMessage(bot.id, resolvedImages.text, threadId);
|
|
7692
8359
|
// The snippet is only the fallback name. A cheap one-shot may trade it
|
|
7693
8360
|
// for a title a person would have typed, but never on a peer-opened
|
|
7694
|
-
// row:
|
|
7695
|
-
//
|
|
7696
|
-
//
|
|
7697
|
-
// while the row still carries the snippet — a rename by the person or
|
|
7698
|
-
// by adoption has already broken that equality by then.
|
|
8361
|
+
// row: its title belongs to the assigned work. Everywhere else, the
|
|
8362
|
+
// swap happens only while the row still carries the snippet — a rename
|
|
8363
|
+
// by a person has already broken that equality by then.
|
|
7699
8364
|
const snippet = titled?.title;
|
|
7700
8365
|
if (titled && snippet && !titled.openedBy?.botId && llmThreadTitlesEnabled(cfg) && instance.generateText) {
|
|
7701
8366
|
void generateThreadTitle(instance, resolvedImages.text)
|
|
@@ -7772,6 +8437,9 @@ async function startTurn(botId, text, opts) {
|
|
|
7772
8437
|
// in the background — boat provisioning can take ~90s and must never
|
|
7773
8438
|
// hang the HTTP request
|
|
7774
8439
|
const dispatchClaimId = randomUUID();
|
|
8440
|
+
// Guest-driven for as long as it runs, wherever it runs (cloudGuestDriven).
|
|
8441
|
+
if (guestConfined)
|
|
8442
|
+
guestDrivenTurns.set(threadId, dispatchClaimId);
|
|
7775
8443
|
const resourceOwner = { threadId, generation: dispatchClaimId };
|
|
7776
8444
|
turnResourceOwners.set(threadId, resourceOwner);
|
|
7777
8445
|
directTurnGenerationByThread.set(threadId, dispatchClaimId);
|
|
@@ -7786,6 +8454,10 @@ async function startTurn(botId, text, opts) {
|
|
|
7786
8454
|
requestGenerations.add(dispatchClaimId);
|
|
7787
8455
|
directRequestOwners.set(threadId, { generation: dispatchClaimId, messageId: requestMessageId,
|
|
7788
8456
|
generations: requestGenerations, turnId: null, ...(opts?.automationSource ? { automation: opts.automationSource } : {}) });
|
|
8457
|
+
// On a Cloud home, a turn that is not provably the owner's leaves this
|
|
8458
|
+
// bot's memory "pending" until it ends (server/lending-memory.ts).
|
|
8459
|
+
if (CLOUD_HOME && cloudHomeLendingRefusal(cloudLendingTurn({ botId, threadId, generation: dispatchClaimId })) !== null)
|
|
8460
|
+
noteForeignTurn(botId, threadId, dispatchClaimId);
|
|
7789
8461
|
// An unknown control-plane wake may invalidate a prior final, but it must
|
|
7790
8462
|
// never acquire authority by guessing the latest user as its origin.
|
|
7791
8463
|
const pendingSource = requestMessageId ?? store.activePath(threadId).findLast(message => message.role === "user")?.id;
|
|
@@ -7911,9 +8583,9 @@ async function startTurn(botId, text, opts) {
|
|
|
7911
8583
|
const externalUpdate = Boolean(opts?.coordination?.resumed || unseen?.some((m) => m.keep));
|
|
7912
8584
|
// What a resumed session keeps from its launch: the standing instructions
|
|
7913
8585
|
// (tools, servers and — for Claude — the model are passed on every launch),
|
|
7914
|
-
// plus
|
|
7915
|
-
//
|
|
7916
|
-
//
|
|
8586
|
+
// plus settings that a live turn cannot change. Codex now reasserts its
|
|
8587
|
+
// model/provider on resume, but an in-flight turn and an omitted effort
|
|
8588
|
+
// retain the prior selection, so both still belong to the session here. An
|
|
7917
8589
|
// external update that finds any of it changed since the session started
|
|
7918
8590
|
// gets the fresh session and replay it always got, rather than a resume.
|
|
7919
8591
|
const persistentConfig = [bot.name, bot.title, bot.description, sectionContextSystemPrompt(bot.section),
|
|
@@ -8014,7 +8686,7 @@ async function startTurn(botId, text, opts) {
|
|
|
8014
8686
|
// composio — only to a driver that can mount them. Their tools are
|
|
8015
8687
|
// never pre-allowed, so every call rides the normal permission flow.
|
|
8016
8688
|
if (instance.adapter.capabilities.customMcp === true) {
|
|
8017
|
-
const custom = engineMcpServers(bot);
|
|
8689
|
+
const custom = await withMcpSignIn(engineMcpServers(bot), mcpOAuth);
|
|
8018
8690
|
if (Object.keys(custom).length)
|
|
8019
8691
|
integrations.custom = custom;
|
|
8020
8692
|
}
|
|
@@ -8026,7 +8698,8 @@ async function startTurn(botId, text, opts) {
|
|
|
8026
8698
|
if (worksInWorkspace) {
|
|
8027
8699
|
ensureWorkspace(bot.id);
|
|
8028
8700
|
// baseline for the journal's turn-boundary diff (see the bus hook)
|
|
8029
|
-
|
|
8701
|
+
if (bot.memoryEnabled !== false)
|
|
8702
|
+
beginMemoryTurn(bot.id, threadId);
|
|
8030
8703
|
}
|
|
8031
8704
|
const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
|
|
8032
8705
|
const skillInstructions = renderSkillInstructions(selectedSkills, {
|
|
@@ -8042,8 +8715,13 @@ async function startTurn(botId, text, opts) {
|
|
|
8042
8715
|
// bot's folder for a task that runs elsewhere.
|
|
8043
8716
|
if (opts?.runOn === "cloud")
|
|
8044
8717
|
store.pinTaskCwd(bot.id, threadId, undefined, { none: true });
|
|
8718
|
+
// On a Cloud home a conversation a guest opened is pinned to its own
|
|
8719
|
+
// folder when it first runs, not the bot's project folder the owner's
|
|
8720
|
+
// conversations share (what it leaves there reaches none of them). One
|
|
8721
|
+
// that ran before keeps its pin, unless the session that opened it was
|
|
8722
|
+
// revoked: then it was unpinned at boot (server/cloud-owner.ts).
|
|
8045
8723
|
const pinnedCwd = privateWorkspace && opts?.runOn !== "cloud"
|
|
8046
|
-
? store.pinTaskCwd(bot.id, threadId, privateWorkspace)
|
|
8724
|
+
? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
|
|
8047
8725
|
: null;
|
|
8048
8726
|
const cwd = pinnedCwd ?? undefined;
|
|
8049
8727
|
if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
|
|
@@ -8133,7 +8811,7 @@ async function startTurn(botId, text, opts) {
|
|
|
8133
8811
|
* desktop, not whatever localVmTargetForBot resolves to by the time
|
|
8134
8812
|
* the first screen call arrives. */
|
|
8135
8813
|
const claimAutoLocalVm = async (claimThreadId, pinnedTarget) => {
|
|
8136
|
-
const localVmTarget = pinnedTarget ?? localVmTargetForThread(bot.id, claimThreadId);
|
|
8814
|
+
const localVmTarget = pinnedTarget ?? localVmThreadTargets.get(claimThreadId) ?? { ...localVmTargetForThread(bot.id, claimThreadId) };
|
|
8137
8815
|
await bindTurnComputer(resourceOwner, `computer:vm:${localVmTarget.key}`, true);
|
|
8138
8816
|
if (localVmImageBusy || localVmModeChangeBusy || localVmLifecycleBusy.has(localVmTarget.key)) {
|
|
8139
8817
|
throw new Error("this Local VM is being started, stopped, or replaced — wait for setup to finish");
|
|
@@ -8157,6 +8835,8 @@ async function startTurn(botId, text, opts) {
|
|
|
8157
8835
|
// without this the exclusive lease would sit held for the rest of a
|
|
8158
8836
|
// turn that never got the VM — the very serialisation #1361 removes.
|
|
8159
8837
|
const dropLease = () => {
|
|
8838
|
+
if (localVmThreadTargets.get(claimThreadId) !== localVmTarget)
|
|
8839
|
+
return;
|
|
8160
8840
|
localVmLeaseFor(localVmTarget).release(claimThreadId);
|
|
8161
8841
|
if (localVmActiveThreads.get(localVmTarget.key) === claimThreadId)
|
|
8162
8842
|
localVmActiveThreads.delete(localVmTarget.key);
|
|
@@ -8239,7 +8919,7 @@ async function startTurn(botId, text, opts) {
|
|
|
8239
8919
|
if (!mountsComputerMcp || instance.adapter.capabilities.remoteAgent === true) {
|
|
8240
8920
|
if (!strict)
|
|
8241
8921
|
return false;
|
|
8242
|
-
throw new Error("this model
|
|
8922
|
+
throw new Error("this model cannot use the Local VM — choose Claude or an ACP model provider, or select another computer destination");
|
|
8243
8923
|
}
|
|
8244
8924
|
if (!strict && localVmMode(cfg) === "pool") {
|
|
8245
8925
|
// localVmTargetForThread records pool affinity, so a turn the
|
|
@@ -8250,7 +8930,7 @@ async function startTurn(botId, text, opts) {
|
|
|
8250
8930
|
if (!localVmSeen.has(poolCandidate.key) && !opts?.automationSource)
|
|
8251
8931
|
return false;
|
|
8252
8932
|
}
|
|
8253
|
-
const localVmTarget = localVmTargetForThread(bot.id, threadId);
|
|
8933
|
+
const localVmTarget = { ...localVmTargetForThread(bot.id, threadId) };
|
|
8254
8934
|
let lazyReadyVm = null;
|
|
8255
8935
|
if (!strict) {
|
|
8256
8936
|
// Nothing this process has ever seen for this target, and nobody is
|
|
@@ -8315,7 +8995,8 @@ async function startTurn(botId, text, opts) {
|
|
|
8315
8995
|
catch (error) {
|
|
8316
8996
|
if (strict)
|
|
8317
8997
|
throw error;
|
|
8318
|
-
|
|
8998
|
+
if (directTurnGenerationByThread.get(threadId) === dispatchClaimId)
|
|
8999
|
+
releaseLocalVmThread(threadId);
|
|
8319
9000
|
return false;
|
|
8320
9001
|
}
|
|
8321
9002
|
};
|
|
@@ -8455,9 +9136,9 @@ async function startTurn(botId, text, opts) {
|
|
|
8455
9136
|
autoVpsProblem &&
|
|
8456
9137
|
!computerSelectionTurns.has(threadId)) {
|
|
8457
9138
|
const hint = opts?.automationSource
|
|
8458
|
-
? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in
|
|
9139
|
+
? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in Settings → API keys."
|
|
8459
9140
|
: bot.autoStartVps
|
|
8460
|
-
? "Check the VPS connection in
|
|
9141
|
+
? "Check the VPS connection in Settings → API keys."
|
|
8461
9142
|
: "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
|
|
8462
9143
|
throw new Error(`${autoVpsProblem}. ${hint}`);
|
|
8463
9144
|
}
|
|
@@ -8503,7 +9184,7 @@ async function startTurn(botId, text, opts) {
|
|
|
8503
9184
|
const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT + (boundedCoordination ? "" : THREADS_PROMPT) : "";
|
|
8504
9185
|
const routinePrompt = integrations.agents ? ROUTINE_PROMPT : "";
|
|
8505
9186
|
const profilePrompt = integrations.agents ? PROFILE_PROMPT : "";
|
|
8506
|
-
const recallPrompt = integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "";
|
|
9187
|
+
const recallPrompt = integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "";
|
|
8507
9188
|
const learnPrompt = skillAuthoring ? LEARN_PROMPT : "";
|
|
8508
9189
|
// (activeVpsThreads was already claimed above, before the provision or
|
|
8509
9190
|
// reuse await, so the backend guards saw this turn the whole time.)
|
|
@@ -8636,8 +9317,8 @@ async function startTurn(botId, text, opts) {
|
|
|
8636
9317
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
8637
9318
|
// what the bot said lately in its other conversations, so a task
|
|
8638
9319
|
// never redoes — or forgets — what another one already did
|
|
8639
|
-
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
|
|
8640
|
-
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace }) },
|
|
9320
|
+
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
|
|
9321
|
+
{ id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false }) },
|
|
8641
9322
|
{ id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
|
|
8642
9323
|
{ id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
|
|
8643
9324
|
{ id: "playbooks", label: "Playbooks", text: packagePlaybooks },
|
|
@@ -8681,7 +9362,8 @@ async function startTurn(botId, text, opts) {
|
|
|
8681
9362
|
text: withRecalled(recalled, dispatchContext.turnText),
|
|
8682
9363
|
refreshSystemPrompt: true,
|
|
8683
9364
|
images: turnImages,
|
|
8684
|
-
approvalMode: approvalModeForTurn(bot, commsDepth > 0),
|
|
9365
|
+
approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
|
|
9366
|
+
...(guestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
|
|
8685
9367
|
model,
|
|
8686
9368
|
effort,
|
|
8687
9369
|
variant,
|
|
@@ -8972,7 +9654,10 @@ function routineRunFallbackText(card) {
|
|
|
8972
9654
|
: card.status === "queued" && card.deferredAt != null
|
|
8973
9655
|
? "deferred: target busy"
|
|
8974
9656
|
: card.status);
|
|
8975
|
-
|
|
9657
|
+
// Clients without the card (older phones) draw only this line, so it
|
|
9658
|
+
// carries what the run said, not just that it finished.
|
|
9659
|
+
const detail = card.summary ?? card.error;
|
|
9660
|
+
return `Routine “${card.routineName}” ${state}${detail ? `\n\n${detail}` : ""}`;
|
|
8976
9661
|
}
|
|
8977
9662
|
/** Upsert one durable lifecycle card per run. Replaying the same transition,
|
|
8978
9663
|
* including restart recovery, patches the existing run id instead of adding
|
|
@@ -9017,9 +9702,14 @@ function syncRoutineRunToSource(run) {
|
|
|
9017
9702
|
const execution = store.taskByThread(run.botId, run.threadId);
|
|
9018
9703
|
const freshExecution = execution && run.status === "running" && !execution.routineRunId &&
|
|
9019
9704
|
store.messagesFor(run.threadId).length === 0;
|
|
9020
|
-
if (execution &&
|
|
9705
|
+
if (execution && freshExecution) {
|
|
9021
9706
|
store.patchTask(run.botId, run.threadId, { routineRunId: run.id, unread: false });
|
|
9022
9707
|
}
|
|
9708
|
+
else if (execution?.routineRunId === run.id && execution.unread) {
|
|
9709
|
+
// Already this run. Clear the dot without rewriting the tag, so a
|
|
9710
|
+
// promoted thread (tag cleared, transcript present) stays visible.
|
|
9711
|
+
store.patchTask(run.botId, run.threadId, { unread: false });
|
|
9712
|
+
}
|
|
9023
9713
|
}
|
|
9024
9714
|
// Merely queueing/running is ambient progress. Attention and terminal
|
|
9025
9715
|
// states become unread in the conversation where the user asked for them.
|
|
@@ -9111,8 +9801,13 @@ routines = new RoutineManager({
|
|
|
9111
9801
|
}
|
|
9112
9802
|
return groupIsWorking(group) || coordinator.busy ? "busy" : "ready";
|
|
9113
9803
|
},
|
|
9114
|
-
createTask: (botId, title, activate = false) => {
|
|
9804
|
+
createTask: (botId, title, activate = false, routineId) => {
|
|
9115
9805
|
const task = store.createTask(botId, title, activate);
|
|
9806
|
+
// On a Cloud home a run's conversation is opened, like its results
|
|
9807
|
+
// conversation, by whoever wrote the routine: a run of one that is
|
|
9808
|
+
// nobody's is confined to a folder of its own, never the bot's project.
|
|
9809
|
+
if (task && CLOUD_HOME && routineId)
|
|
9810
|
+
threadStarters.set(task.threadId, routineOpener(routineId));
|
|
9116
9811
|
const bot = store.bot(botId);
|
|
9117
9812
|
if (task && bot)
|
|
9118
9813
|
broadcast({ kind: "bot", bot: publicBot(bot) });
|
|
@@ -9140,7 +9835,12 @@ routines = new RoutineManager({
|
|
|
9140
9835
|
// explicit resultsThreadId must be a visible task owned by the running bot.
|
|
9141
9836
|
if (!forceNew && routineSourceOwner(routine))
|
|
9142
9837
|
return routine.resultsThreadId;
|
|
9143
|
-
|
|
9838
|
+
const threadId = store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
|
|
9839
|
+
// On a Cloud home the results conversation is opened by whoever wrote the
|
|
9840
|
+
// routine: it carries their name for it (cloudGuestOpened).
|
|
9841
|
+
if (threadId && CLOUD_HOME)
|
|
9842
|
+
threadStarters.set(threadId, routineOpener(routine.id));
|
|
9843
|
+
return threadId;
|
|
9144
9844
|
},
|
|
9145
9845
|
discardResultsThread: (botId, threadId) => {
|
|
9146
9846
|
const task = store.taskByThread(botId, threadId);
|
|
@@ -9202,6 +9902,63 @@ routines = new RoutineManager({
|
|
|
9202
9902
|
notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
|
|
9203
9903
|
},
|
|
9204
9904
|
});
|
|
9905
|
+
// A Cloud home is personal (server/cloud-owner.ts): every stored session
|
|
9906
|
+
// without admin scope is revoked and what it opened or wrote is nobody's;
|
|
9907
|
+
// once (and after a restore) the earlier keys are settled. Before anything
|
|
9908
|
+
// is served, and before any routine can run.
|
|
9909
|
+
if (CLOUD_HOME && CLOUD_OWNER_KEY && cloudRoutineAuthors) {
|
|
9910
|
+
const authors = cloudRoutineAuthors;
|
|
9911
|
+
const routineById = (id) => routines?.listRoutines().find((routine) => routine.id === id);
|
|
9912
|
+
cloudEarlierOwners = settleCloudOwnership({
|
|
9913
|
+
file: join(DATA_DIR, "cloud-owner.json"),
|
|
9914
|
+
machineId: CLOUD_HOME.machineId,
|
|
9915
|
+
ownerKey: CLOUD_OWNER_KEY,
|
|
9916
|
+
nobodyKey: CLOUD_NOBODY_KEY,
|
|
9917
|
+
sessions,
|
|
9918
|
+
personKey,
|
|
9919
|
+
starters: threadStarters,
|
|
9920
|
+
writers: authors,
|
|
9921
|
+
routines: {
|
|
9922
|
+
ids: () => (routines?.listRoutines() ?? []).map((routine) => routine.id),
|
|
9923
|
+
writer: (id) => authors.writer(id),
|
|
9924
|
+
fingerprinted: (id) => { const routine = routineById(id); return Boolean(routine) && authors.authored(id, routine); },
|
|
9925
|
+
resultsOpener: (id) => { const results = routineById(id)?.resultsThreadId; return results ? threadStarters.get(results) : undefined; },
|
|
9926
|
+
shape: (id) => { const routine = routineById(id); return routine ? approvalShape(routine) : undefined; },
|
|
9927
|
+
name: (id, person) => authors.wrote(id, person),
|
|
9928
|
+
pause: (ids) => { for (const id of ids)
|
|
9929
|
+
routines?.update(id, { enabled: false }); },
|
|
9930
|
+
},
|
|
9931
|
+
unpin: (threadIds) => { for (const threadId of threadIds)
|
|
9932
|
+
store.unpinTaskCwd(threadId); },
|
|
9933
|
+
lines: () => {
|
|
9934
|
+
const senders = new Set(), answerers = new Set();
|
|
9935
|
+
const approvals = [];
|
|
9936
|
+
const threads = new Set([...store.bots.flatMap((bot) => store.tasks(bot.id).map((task) => task.threadId)), ...store.groups.map((group) => group.threadId)]);
|
|
9937
|
+
for (const threadId of threads) {
|
|
9938
|
+
for (const line of store.messagesFor(threadId)) {
|
|
9939
|
+
const sender = linePersonKey(line);
|
|
9940
|
+
if (sender)
|
|
9941
|
+
senders.add(sender);
|
|
9942
|
+
const answerer = line.card?.answeredBy?.kind === "session" ? line.card.answeredBy.person : undefined;
|
|
9943
|
+
if (answerer)
|
|
9944
|
+
answerers.add(answerer);
|
|
9945
|
+
// A routine a bot proposed, created when this person allowed its card.
|
|
9946
|
+
const request = line.card?.routineRequest;
|
|
9947
|
+
if (answerer && line.card?.answered === "allow" && request?.operation.action === "create" && request.resultId) {
|
|
9948
|
+
const definition = request.operation.routine;
|
|
9949
|
+
approvals.push([request.resultId, answerer, approvalShape({ prompt: definition.instructions, botId: request.operation.forBot?.botId ?? request.botId,
|
|
9950
|
+
runOn: definition.runOn, schedule: asSchedule(definition.schedule, 0) })]);
|
|
9951
|
+
}
|
|
9952
|
+
}
|
|
9953
|
+
}
|
|
9954
|
+
return { senders, answerers, approvals };
|
|
9955
|
+
},
|
|
9956
|
+
...(restoredAtStart ? { restoredNow: restoredAtStart } : {}),
|
|
9957
|
+
...(workspaceRestore.restored && workspaceRestore.id ? { lastRestore: workspaceRestore.id } : {}),
|
|
9958
|
+
log: (line) => console.log(line),
|
|
9959
|
+
});
|
|
9960
|
+
ownerOnlyCache.clear();
|
|
9961
|
+
}
|
|
9205
9962
|
orgLibrary = new OrgLibrary({
|
|
9206
9963
|
dataDir: DATA_DIR,
|
|
9207
9964
|
store,
|
|
@@ -9276,7 +10033,7 @@ async function cloudRoutineReadiness(botId, threadId) {
|
|
|
9276
10033
|
return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
|
|
9277
10034
|
try {
|
|
9278
10035
|
if ((await instance.snapshot()).state !== "available") {
|
|
9279
|
-
return { ready: false, reason: "The target bot's model
|
|
10036
|
+
return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
|
|
9280
10037
|
}
|
|
9281
10038
|
const teamComputer = inheritedTeamComputer(bot);
|
|
9282
10039
|
const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
|
|
@@ -9712,7 +10469,7 @@ async function resolveAndSendTeamSetup(res, args, ownerReview) {
|
|
|
9712
10469
|
if (!card)
|
|
9713
10470
|
return false;
|
|
9714
10471
|
if (args.behavior === "allow" && !ownerReview) {
|
|
9715
|
-
json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired
|
|
10472
|
+
json(res, 403, { error: "Approve team setup or deletion from the desktop app or a paired Full-access device. In a local browser, wait until every bot is idle." });
|
|
9716
10473
|
return true;
|
|
9717
10474
|
}
|
|
9718
10475
|
if (args.behavior === "allow" && !card.answered && !card.dismissed && !card.expired) {
|
|
@@ -9829,9 +10586,15 @@ function sendRoutineResolution(res, result) {
|
|
|
9829
10586
|
});
|
|
9830
10587
|
return true;
|
|
9831
10588
|
}
|
|
9832
|
-
function resolveAndSendRoutine(res, args) {
|
|
10589
|
+
function resolveAndSendRoutine(res, args, ownersAnswer = false) {
|
|
9833
10590
|
const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.routineRequest)?.card;
|
|
10591
|
+
// On a Cloud home a routine the owner approved on its card is theirs; one
|
|
10592
|
+
// it only changed stays whosever it was (cloudOwnerApplied).
|
|
10593
|
+
const operation = card?.routineRequest?.operation;
|
|
10594
|
+
const wasOwners = operation && operation.action !== "create" ? cloudRoutineIsOwners(operation.routineId) : false;
|
|
9834
10595
|
const result = routineRequests.resolve(args);
|
|
10596
|
+
if (ownersAnswer && result.claimed && result.state === "applied")
|
|
10597
|
+
cloudOwnerApplied(result.action, result.resultId, wasOwners);
|
|
9835
10598
|
if (result.claimed &&
|
|
9836
10599
|
(result.state === "applied" || result.state === "denied")) {
|
|
9837
10600
|
appendDecision(DATA_DIR, {
|
|
@@ -9967,12 +10730,25 @@ const webhooks = new WebhookManager({
|
|
|
9967
10730
|
findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
|
|
9968
10731
|
cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
|
|
9969
10732
|
pendingRuns: (webhookId) => routines.activeWebhookRunCount(webhookId),
|
|
9970
|
-
// delivery:"post" webhooks land in
|
|
9971
|
-
|
|
9972
|
-
|
|
9973
|
-
|
|
10733
|
+
// delivery:"post" webhooks land in a dedicated "Updates" task, never
|
|
10734
|
+
// bot.threadId (the bot's currently-selected task) -- see
|
|
10735
|
+
// resolvePostThread below. Fixes
|
|
10736
|
+
// https://github.com/milind-soni/OpenMausBot/issues/2071: a post used to
|
|
10737
|
+
// land wherever the owner (or another automation) had last switched
|
|
10738
|
+
// that bot's selection, including a live conversation.
|
|
10739
|
+
post: (botId, threadId, text) => {
|
|
10740
|
+
if (!store.bot(botId))
|
|
9974
10741
|
return;
|
|
9975
|
-
store.appendMessage(
|
|
10742
|
+
store.appendMessage(threadId, { role: "bot", kind: "text", text });
|
|
10743
|
+
},
|
|
10744
|
+
// Mirrors resolveResultsThread's routines wiring a few hundred lines up
|
|
10745
|
+
// in this same file: create-on-first-use, never activated (so it never
|
|
10746
|
+
// steals the bot's live selection the way POST /api/bots/:id/tasks
|
|
10747
|
+
// does), reused forever after via trigger.resultsThreadId.
|
|
10748
|
+
resolvePostThread: (trigger, forceNew) => {
|
|
10749
|
+
if (!forceNew && trigger.resultsThreadId)
|
|
10750
|
+
return trigger.resultsThreadId;
|
|
10751
|
+
return store.createTask(trigger.botId, "Updates", false)?.threadId;
|
|
9976
10752
|
},
|
|
9977
10753
|
});
|
|
9978
10754
|
let webhookIngress = null;
|
|
@@ -10144,7 +10920,7 @@ setupRetry = 0) {
|
|
|
10144
10920
|
// delegated Full elevation makes the two disagree by construction: every
|
|
10145
10921
|
// such room turn then reads as "settings changed", retries once, and
|
|
10146
10922
|
// settles as busy without ever dispatching.
|
|
10147
|
-
const preparedApprovalMode = roomTurnApprovalMode(bot, orchestration);
|
|
10923
|
+
const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
|
|
10148
10924
|
const preparedSelection = { ...bot.modelSelection };
|
|
10149
10925
|
const preparedComposio = bot.composio;
|
|
10150
10926
|
const instance = turnInstance(bot);
|
|
@@ -10154,8 +10930,14 @@ setupRetry = 0) {
|
|
|
10154
10930
|
return true;
|
|
10155
10931
|
}
|
|
10156
10932
|
const roomPolicyRefusal = instance ? policyModelRefusal(instance) : undefined;
|
|
10157
|
-
|
|
10158
|
-
|
|
10933
|
+
// A room turn a guest drives on a Cloud home is confined like a guest's
|
|
10934
|
+
// own conversation, or refused on an engine that cannot be.
|
|
10935
|
+
if (!groupSpeakers.has(threadId))
|
|
10936
|
+
guestDrivenTurns.delete(threadId);
|
|
10937
|
+
const roomGuestConfined = cloudGuestDriven(threadId) || cloudGuestDrivenHandoff(orchestration?.roomHandoffId);
|
|
10938
|
+
const roomGuestRefusal = roomGuestConfined && instance?.adapter.capabilities.guestTurns !== "confined" ? guestEngineRefusal(threadId) : undefined;
|
|
10939
|
+
if (!instance || roomPolicyRefusal || roomGuestRefusal) {
|
|
10940
|
+
const message = roomPolicyRefusal ?? roomGuestRefusal ?? `${bot.name}'s model is unavailable`;
|
|
10159
10941
|
store.appendMessage(threadId, {
|
|
10160
10942
|
role: "bot",
|
|
10161
10943
|
kind: "activity",
|
|
@@ -10188,6 +10970,11 @@ setupRetry = 0) {
|
|
|
10188
10970
|
return true;
|
|
10189
10971
|
}
|
|
10190
10972
|
const internalGeneration = beginInternalCapabilityGeneration(threadId);
|
|
10973
|
+
if (roomGuestConfined)
|
|
10974
|
+
guestDrivenTurns.set(threadId, internalGeneration);
|
|
10975
|
+
// A room turn is never provably the owner's alone (server/lending-memory.ts).
|
|
10976
|
+
if (CLOUD_HOME)
|
|
10977
|
+
noteForeignTurn(bot.id, threadId, internalGeneration);
|
|
10191
10978
|
const resourceOwner = { threadId, generation: internalGeneration };
|
|
10192
10979
|
turnResourceOwners.set(threadId, resourceOwner);
|
|
10193
10980
|
let roomVmTarget = null;
|
|
@@ -10294,7 +11081,7 @@ setupRetry = 0) {
|
|
|
10294
11081
|
}
|
|
10295
11082
|
// user-configured MCP servers: same gating as the 1:1 site above.
|
|
10296
11083
|
if (instance.adapter.capabilities.customMcp === true) {
|
|
10297
|
-
const custom = engineMcpServers(bot);
|
|
11084
|
+
const custom = await withMcpSignIn(engineMcpServers(bot), mcpOAuth);
|
|
10298
11085
|
if (Object.keys(custom).length)
|
|
10299
11086
|
integrations.custom = custom;
|
|
10300
11087
|
}
|
|
@@ -10316,7 +11103,7 @@ setupRetry = 0) {
|
|
|
10316
11103
|
if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
|
|
10317
11104
|
return false;
|
|
10318
11105
|
const setupChanged = turnInstance(readyBot) !== instance ||
|
|
10319
|
-
roomTurnApprovalMode(readyBot, orchestration) !== preparedApprovalMode ||
|
|
11106
|
+
roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
|
|
10320
11107
|
readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
|
|
10321
11108
|
readyBot.modelSelection.model !== preparedSelection.model ||
|
|
10322
11109
|
readyBot.modelSelection.effort !== preparedSelection.effort ||
|
|
@@ -10520,7 +11307,7 @@ setupRetry = 0) {
|
|
|
10520
11307
|
// Claim the same lease as direct turns before asynchronous VM setup.
|
|
10521
11308
|
if (readyBot.computer === "vm") {
|
|
10522
11309
|
if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
|
|
10523
|
-
throw new Error("this model
|
|
11310
|
+
throw new Error("this model cannot use the Local VM");
|
|
10524
11311
|
}
|
|
10525
11312
|
// A distinct identity fences cleanup even in shared mode on the same room thread.
|
|
10526
11313
|
const target = { ...localVmTargetForThread(readyBot.id, threadId) };
|
|
@@ -10613,7 +11400,7 @@ setupRetry = 0) {
|
|
|
10613
11400
|
const worksInWorkspace = supportsWorkspaceFiles(instance.driverKind);
|
|
10614
11401
|
const workspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
|
|
10615
11402
|
// a room member's memory writes are journaled the same as a 1:1 turn's
|
|
10616
|
-
if (workspace)
|
|
11403
|
+
if (workspace && bot.memoryEnabled !== false)
|
|
10617
11404
|
beginMemoryTurn(bot.id, threadId);
|
|
10618
11405
|
// The room's folder pins here — on the first turn that actually
|
|
10619
11406
|
// dispatches, not at PATCH time — so a folder set on a never-used room
|
|
@@ -10621,19 +11408,22 @@ setupRetry = 0) {
|
|
|
10621
11408
|
// has its folder moved underneath it. Off-host members skip the folder
|
|
10622
11409
|
// but must not decide the pin: the room's desk is a property of the
|
|
10623
11410
|
// room, not of whichever member happened to speak first.
|
|
10624
|
-
|
|
11411
|
+
// A room a guest opened on a Cloud home works in a folder of its own too.
|
|
11412
|
+
const cwd = workspace && cloudGuestOpened(threadId)
|
|
11413
|
+
? ensureTaskWorkspace(bot.id, threadId)
|
|
11414
|
+
: groupTurnCwd(workspace, () => store.pinGroupCwd(readyGroup.id, threadId));
|
|
10625
11415
|
{
|
|
10626
11416
|
const drift = checkSoulDrift(bot.id, bot.soul ?? "", bot.soulHash ?? "");
|
|
10627
11417
|
if (drift.drift !== Boolean(bot.soulDrift))
|
|
10628
11418
|
store.patchBot(bot.id, { soulDrift: drift.drift });
|
|
10629
11419
|
}
|
|
10630
|
-
const roomMemory = memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace });
|
|
11420
|
+
const roomMemory = memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false });
|
|
10631
11421
|
// The same brief a 1:1 turn gets: what this member said lately in its
|
|
10632
11422
|
// other conversations, so a standup is answered from what happened. A
|
|
10633
11423
|
// brief can carry a private chat into the room; as with session_search
|
|
10634
11424
|
// (#754) the room is told, once per source thread, rather than the
|
|
10635
11425
|
// crossing being blocked.
|
|
10636
|
-
const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId });
|
|
11426
|
+
const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
|
|
10637
11427
|
const roomComputerPromptKind = resolveComputerPromptKind({
|
|
10638
11428
|
kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
|
|
10639
11429
|
driverKind: instance.driverKind, cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
|
|
@@ -10659,7 +11449,7 @@ setupRetry = 0) {
|
|
|
10659
11449
|
{ id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
|
|
10660
11450
|
{ id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
|
|
10661
11451
|
{ id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
|
|
10662
|
-
{ id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
11452
|
+
{ id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
|
|
10663
11453
|
{ id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
|
|
10664
11454
|
{ id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
|
|
10665
11455
|
// the room path has always put a newline before memory and trimmed
|
|
@@ -10794,7 +11584,8 @@ setupRetry = 0) {
|
|
|
10794
11584
|
text: withRecalled(roomRecalled, text),
|
|
10795
11585
|
refreshSystemPrompt: true,
|
|
10796
11586
|
images: turnImages,
|
|
10797
|
-
approvalMode: roomTurnApprovalMode(readyBot, orchestration),
|
|
11587
|
+
approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
|
|
11588
|
+
...(roomGuestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
|
|
10798
11589
|
system: roomSystem.text,
|
|
10799
11590
|
systemStable: roomSystem.stable,
|
|
10800
11591
|
systemVolatile: roomSystem.volatile,
|
|
@@ -12443,38 +13234,19 @@ function stderrOf(err) {
|
|
|
12443
13234
|
const s = err.stderr;
|
|
12444
13235
|
return typeof s === "string" ? s : Buffer.isBuffer(s) ? s.toString("utf8") : "";
|
|
12445
13236
|
}
|
|
12446
|
-
async function localVmPayload(target) {
|
|
13237
|
+
async function localVmPayload(target, auth) {
|
|
12447
13238
|
const status = await containerComputerStatus(undefined, undefined, target);
|
|
12448
13239
|
return {
|
|
12449
|
-
...status,
|
|
13240
|
+
...localVmViewerStatus(status, auth),
|
|
13241
|
+
stop_reason: localVmStopReason(target.key, status),
|
|
12450
13242
|
commands: setupCommands(status.runtime, process.platform, target),
|
|
12451
|
-
idle_timeout_ms:
|
|
13243
|
+
idle_timeout_ms: localVmIdleMs(),
|
|
12452
13244
|
mode: localVmMode(cfg),
|
|
12453
13245
|
max_instances: localVmMaxInstances(cfg),
|
|
12454
13246
|
};
|
|
12455
13247
|
}
|
|
12456
|
-
/**
|
|
12457
|
-
*
|
|
12458
|
-
* `LocalVmIdleTimer` REMOVES an unused Local VM rather than pausing it. The
|
|
12459
|
-
* turn then failed with "Create the Local VM (App Settings → Local VM)" —
|
|
12460
|
-
* which reads like a fault the person must repair by hand, for a container the
|
|
12461
|
-
* app itself deleted eight hours earlier. Someone who steps away overnight
|
|
12462
|
-
* comes back to an error on their first message.
|
|
12463
|
-
*
|
|
12464
|
-
* The cloud branch below already does the opposite: an absent boat is
|
|
12465
|
-
* provisioned on first use behind a `provisioning` broadcast. This gives the
|
|
12466
|
-
* Local VM the same lifecycle for the same reason.
|
|
12467
|
-
*
|
|
12468
|
-
* Only `missing` is recovered, and only when a fresh `run` is all it takes.
|
|
12469
|
-
* Every other problem still surfaces: no runtime installed, no image pulled,
|
|
12470
|
-
* `create_supported` false, or an existing container that is stale, unmanaged
|
|
12471
|
-
* or unsafe. Those need a decision — install podman, download 1.4 GB, replace
|
|
12472
|
-
* a container someone else made — and a stopped container is deliberately not
|
|
12473
|
-
* resumed here, because `localVmProblem` says this desktop image cannot safely
|
|
12474
|
-
* resume and asks for a recreate rather than a start. Per-bot mode keeps its
|
|
12475
|
-
* instance cap; creating past it would quietly do what the lifecycle route
|
|
12476
|
-
* refuses.
|
|
12477
|
-
*/
|
|
13248
|
+
/** Wake an idle desktop, or recreate a missing one from an already prepared
|
|
13249
|
+
* image. Incompatible or unsafe containers still require an explicit decision. */
|
|
12478
13250
|
async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
|
|
12479
13251
|
localVmLifecycleBusy.add(target.key);
|
|
12480
13252
|
// Fence this target, and the cross-target capacity decision for creates,
|
|
@@ -12494,21 +13266,22 @@ async function readyLocalVmForTurn(botId, target, isCurrent = () => true) {
|
|
|
12494
13266
|
noteLocalVmSeen(target, status);
|
|
12495
13267
|
if (!isCurrent())
|
|
12496
13268
|
return status;
|
|
12497
|
-
|
|
13269
|
+
const action = localVmWakeAction(status);
|
|
13270
|
+
if (status.ready || !action || !status.runtime)
|
|
12498
13271
|
return status;
|
|
12499
13272
|
// Another creation is already mid-flight and its container is not yet
|
|
12500
13273
|
// visible to a count, so the safe answer is the inspected status —
|
|
12501
13274
|
// exactly what the over-cap path below returns.
|
|
12502
|
-
if (!pooled && !ownsProvision)
|
|
13275
|
+
if (action === "run" && !pooled && !ownsProvision)
|
|
12503
13276
|
return status;
|
|
12504
|
-
if (target.key.startsWith("bot:")) {
|
|
13277
|
+
if (action === "run" && target.key.startsWith("bot:")) {
|
|
12505
13278
|
const count = await existingPerBotLocalVmCount(status.runtime);
|
|
12506
13279
|
if (!isCurrent() || count >= localVmMaxInstances(cfg))
|
|
12507
13280
|
return status;
|
|
12508
13281
|
}
|
|
12509
13282
|
broadcast({ kind: "computer", botId, state: "provisioning" });
|
|
12510
13283
|
try {
|
|
12511
|
-
status = await containerComputerAction(
|
|
13284
|
+
status = await containerComputerAction(action, undefined, undefined, target);
|
|
12512
13285
|
}
|
|
12513
13286
|
catch {
|
|
12514
13287
|
// Keep the inspected status: its `problem` names the real obstacle,
|
|
@@ -12576,7 +13349,10 @@ function configStatus() {
|
|
|
12576
13349
|
return {
|
|
12577
13350
|
xai: { configured: Boolean(cfg.xai?.key) },
|
|
12578
13351
|
mistral: { configured: Boolean(cfg.mistral?.key) },
|
|
12579
|
-
|
|
13352
|
+
cerebras: { configured: Boolean(cfg.cerebras?.key) },
|
|
13353
|
+
anthropic: { configured: Boolean(cfg.anthropic?.key), everyClaudeBot: cfg.anthropic?.everyClaudeBot !== false },
|
|
13354
|
+
openai: { configured: Boolean(cfg.openai?.key) },
|
|
13355
|
+
openrouter: { configured: Boolean(cfg.openrouter?.key) },
|
|
12580
13356
|
// a fleet agent on this server means Settings → Workspaces has something to drive
|
|
12581
13357
|
fleet: { available: fleetAvailable(fleetSocketPath()) },
|
|
12582
13358
|
// what this build is entitled to, so Settings shows only what works here
|
|
@@ -12627,6 +13403,7 @@ function configStatus() {
|
|
|
12627
13403
|
localVm: {
|
|
12628
13404
|
mode: localVmMode(cfg),
|
|
12629
13405
|
maxInstances: localVmMaxInstances(cfg),
|
|
13406
|
+
idleTimeoutMinutes: localVmIdleTimeoutMinutes(cfg),
|
|
12630
13407
|
},
|
|
12631
13408
|
features: {
|
|
12632
13409
|
skillAuthoring: skillAuthoringEnabled(cfg),
|
|
@@ -12641,6 +13418,8 @@ function configStatus() {
|
|
|
12641
13418
|
// own Claude Code MCP servers
|
|
12642
13419
|
claudeUserMcp: claudeUserMcpEnabled(cfg),
|
|
12643
13420
|
autoRecall: autoRecallEnabled(cfg),
|
|
13421
|
+
// hand-edited config.json only; the thread menu offers Regenerate title from it
|
|
13422
|
+
llmThreadTitles: llmThreadTitlesEnabled(cfg),
|
|
12644
13423
|
},
|
|
12645
13424
|
// first-run progress — not a secret; the app decides whether to show
|
|
12646
13425
|
// the welcome tour from this, never from browser storage
|
|
@@ -12649,6 +13428,7 @@ function configStatus() {
|
|
|
12649
13428
|
version: cfg.onboarding?.version ?? 0,
|
|
12650
13429
|
reelSeen: cfg.onboarding?.reelSeen === true,
|
|
12651
13430
|
hintsSeen: cfg.onboarding?.hintsSeen ?? [],
|
|
13431
|
+
...(cfg.onboarding?.firstTurnAt ? { firstTurnAt: cfg.onboarding.firstTurnAt } : {}),
|
|
12652
13432
|
},
|
|
12653
13433
|
// Which browser this server can give bots: the desktop app's surface,
|
|
12654
13434
|
// the agent-browser engine, or nothing yet (with the reason).
|
|
@@ -12682,7 +13462,14 @@ function mcpServerResponse() {
|
|
|
12682
13462
|
// While enrolled with custom servers off, say which servers stay configured
|
|
12683
13463
|
// but never reach bots, and why. Nothing here is written to config.json.
|
|
12684
13464
|
const policy = managedPolicy.current();
|
|
12685
|
-
const servers = listMcpServers(cfg.mcpServers).map(
|
|
13465
|
+
const servers = listMcpServers(cfg.mcpServers).map((server) => {
|
|
13466
|
+
if (!("url" in server))
|
|
13467
|
+
return server;
|
|
13468
|
+
const auth = mcpOAuth.authState(server.name, server.url);
|
|
13469
|
+
// the redirect URI to register with a pre-registered sign-in app
|
|
13470
|
+
const listed = server.oauth ? { ...server, oauth: { ...server.oauth, redirectUri: mcpOAuthRedirectUri(server.url) } } : server;
|
|
13471
|
+
return auth === "none" ? listed : { ...listed, auth };
|
|
13472
|
+
}).map(server => managedPolicy.mcpAllowed(server.name, "url" in server ? server.url : undefined) ? server : { ...server, managedBy: policy.organizationName });
|
|
12686
13473
|
return { servers, ...(policy && !policy.mcp.allowCustom ? { managed: { organizationName: policy.organizationName, allowlist: policy.mcp.allowlist } } : {}) };
|
|
12687
13474
|
}
|
|
12688
13475
|
/** Adding or editing a server the organisation has not approved is refused. */
|
|
@@ -12697,7 +13484,7 @@ function mcpServerBody(body) {
|
|
|
12697
13484
|
if (!body || typeof body !== "object")
|
|
12698
13485
|
return out;
|
|
12699
13486
|
const record = body;
|
|
12700
|
-
for (const key of ["command", "args", "env", "type", "url", "headers", "enabled"]) {
|
|
13487
|
+
for (const key of ["command", "args", "env", "type", "url", "headers", "oauth", "enabled"]) {
|
|
12701
13488
|
if (record[key] !== undefined)
|
|
12702
13489
|
out[key] = record[key];
|
|
12703
13490
|
}
|
|
@@ -12706,7 +13493,7 @@ function mcpServerBody(body) {
|
|
|
12706
13493
|
/** Configured MCP servers that may reach this bot's engine. While enrolled,
|
|
12707
13494
|
* the organisation's allow-list filters them; config.json is never changed. */
|
|
12708
13495
|
function engineMcpServers(bot) {
|
|
12709
|
-
return managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers));
|
|
13496
|
+
return withoutPendingSignIn(managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers)), mcpOAuth);
|
|
12710
13497
|
}
|
|
12711
13498
|
function persistMcpServers(next) {
|
|
12712
13499
|
saveConfig({ mcpServers: next });
|
|
@@ -12720,7 +13507,11 @@ async function describeInstances() {
|
|
|
12720
13507
|
const configs = providerConfigs();
|
|
12721
13508
|
return (await registry.describe()).map((instance) => {
|
|
12722
13509
|
const entry = configs[instance.instanceId];
|
|
12723
|
-
const described =
|
|
13510
|
+
const described = {
|
|
13511
|
+
...instance,
|
|
13512
|
+
...(entry?.icon ? { icon: entry.icon } : {}),
|
|
13513
|
+
...(entry?.access ? { access: entry.access } : {}),
|
|
13514
|
+
};
|
|
12724
13515
|
if (hostedModels)
|
|
12725
13516
|
return { ...described, readOnly: true,
|
|
12726
13517
|
install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
|
|
@@ -12732,8 +13523,16 @@ async function describeInstances() {
|
|
|
12732
13523
|
...described, ...policy, readOnly: true, managed: managedDesktop.info(instance.instanceId),
|
|
12733
13524
|
install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
|
|
12734
13525
|
};
|
|
13526
|
+
// Windows keeps what Antigravity's runtime unpacked whenever it is
|
|
13527
|
+
// stopped by force; its Settings card offers to clear that.
|
|
13528
|
+
if (instance.driverKind === "antigravityAgent" && process.platform === "win32")
|
|
13529
|
+
return { ...described, ...policy, freeUpSpace: true };
|
|
12735
13530
|
if (entry?.driver !== "claudeAgent")
|
|
12736
13531
|
return { ...described, ...policy };
|
|
13532
|
+
// Claude on the workspace API key has no account to sign in to.
|
|
13533
|
+
if (instance.instanceId === CLAUDE_API_INSTANCE) {
|
|
13534
|
+
return { ...described, ...policy, authentication: undefined, install: { ...instance.install, signInCommand: undefined } };
|
|
13535
|
+
}
|
|
12737
13536
|
try {
|
|
12738
13537
|
const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
|
|
12739
13538
|
return { ...described, ...policy, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
|
|
@@ -12872,6 +13671,7 @@ async function reloadProviders() {
|
|
|
12872
13671
|
stopScreenPoller(botId, threadId);
|
|
12873
13672
|
releaseLocalVmThread(threadId);
|
|
12874
13673
|
releaseTurnResources(owner);
|
|
13674
|
+
endForeignTurns(threadId);
|
|
12875
13675
|
vpsThreadEnded(botId, threadId);
|
|
12876
13676
|
watchdog.settle(threadId);
|
|
12877
13677
|
closeOpenApprovals(threadId);
|
|
@@ -12891,6 +13691,7 @@ async function reloadProviders() {
|
|
|
12891
13691
|
for (const [threadId, speaker] of rooms) {
|
|
12892
13692
|
releaseLocalVmThread(threadId);
|
|
12893
13693
|
releaseTurnResources(turnResourceOwners.get(threadId));
|
|
13694
|
+
endForeignTurns(threadId);
|
|
12894
13695
|
watchdog.settle(threadId);
|
|
12895
13696
|
closeOpenApprovals(threadId);
|
|
12896
13697
|
if (groupSpeakers.get(threadId) !== speaker)
|
|
@@ -12922,6 +13723,10 @@ let mcpProbesInFlight = 0;
|
|
|
12922
13723
|
// install, and running two self-updates against it would race its files.
|
|
12923
13724
|
const claudeUpdatesInFlight = new Set();
|
|
12924
13725
|
// ── HTTP plumbing ─────────────────────────────────────────────────────
|
|
13726
|
+
/** The web UI's pages: never framed by another page (a hidden frame could
|
|
13727
|
+
* otherwise act on a pair or sign-in page unseen), and they send no Referer.
|
|
13728
|
+
* Nothing frames the web UI: the desktop app shows it in its own window. */
|
|
13729
|
+
const PAGE_HEADERS = { "content-security-policy": "frame-ancestors 'none'", "x-frame-options": "DENY", "referrer-policy": "no-referrer" };
|
|
12925
13730
|
/** The built UI, when this process serves it (OMB_STATIC_DIR: set by the
|
|
12926
13731
|
* desktop app and by the container image). Public by design: it is the same
|
|
12927
13732
|
* bundle anyone can download, holds no secrets, and a remote browser must be
|
|
@@ -12934,7 +13739,8 @@ function serveStatic(res, path) {
|
|
|
12934
13739
|
const file = join(STATIC_DIR, safe);
|
|
12935
13740
|
try {
|
|
12936
13741
|
const data = readFileSync(file);
|
|
12937
|
-
|
|
13742
|
+
const type = MIME[extname(file)] ?? "application/octet-stream";
|
|
13743
|
+
res.writeHead(200, { "content-type": type, ...(type === "text/html" ? PAGE_HEADERS : {}) });
|
|
12938
13744
|
res.end(data);
|
|
12939
13745
|
return true;
|
|
12940
13746
|
}
|
|
@@ -12942,7 +13748,7 @@ function serveStatic(res, path) {
|
|
|
12942
13748
|
// SPA fallback
|
|
12943
13749
|
try {
|
|
12944
13750
|
const data = readFileSync(join(STATIC_DIR, "index.html"));
|
|
12945
|
-
res.writeHead(200, { "content-type": "text/html" });
|
|
13751
|
+
res.writeHead(200, { "content-type": "text/html", ...PAGE_HEADERS });
|
|
12946
13752
|
res.end(data);
|
|
12947
13753
|
return true;
|
|
12948
13754
|
}
|
|
@@ -13026,6 +13832,23 @@ ROUTES.push(createBotMemoryRoutes({
|
|
|
13026
13832
|
bot: (id) => store.bot(id),
|
|
13027
13833
|
taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
|
|
13028
13834
|
upkeep: memoryUpkeep,
|
|
13835
|
+
...(lendingMemory ? {
|
|
13836
|
+
lendingReview: {
|
|
13837
|
+
needed: (botId) => {
|
|
13838
|
+
if (!lendingMemory.reconcile(botId, foreignTurnRunning(botId), { cached: true }).changedBySomeoneElse)
|
|
13839
|
+
return null;
|
|
13840
|
+
const { token, changed } = lendingMemory.reviewInfo(botId);
|
|
13841
|
+
return { token, changed };
|
|
13842
|
+
},
|
|
13843
|
+
accept: (botId, token) => lendingMemory.review(botId, foreignTurnRunning(botId), token).ok,
|
|
13844
|
+
// Like Move to Cloud: one of the owner's own devices, never loopback
|
|
13845
|
+
// (on a Cloud home that is a bot's shell, not the owner).
|
|
13846
|
+
owner: cloudOwnerSession,
|
|
13847
|
+
},
|
|
13848
|
+
// The owner's own Memory edits are theirs even while someone else's
|
|
13849
|
+
// conversation runs; anyone else's are judged like a bot's own write.
|
|
13850
|
+
ownersWrite: (auth, botId, write) => cloudOwnerSession(auth) ? lendingMemory.trustedWrite(botId, write) : write(),
|
|
13851
|
+
} : {}),
|
|
13029
13852
|
aboutMe: () => cfg.profile?.aboutMe ?? "",
|
|
13030
13853
|
saveAboutMe: (aboutMe) => {
|
|
13031
13854
|
saveConfig({ profile: { ...cfg.profile, aboutMe } });
|
|
@@ -13034,6 +13857,13 @@ ROUTES.push(createBotMemoryRoutes({
|
|
|
13034
13857
|
},
|
|
13035
13858
|
}));
|
|
13036
13859
|
ROUTES.push(createDeciderRoutes({ decider }));
|
|
13860
|
+
ROUTES.push(createAntigravityLeftoverRoutes({
|
|
13861
|
+
hosted: Boolean(hostedModels),
|
|
13862
|
+
isAntigravity: (instanceId) => registry.get(instanceId)?.driverKind === "antigravityAgent",
|
|
13863
|
+
find: () => findAntigravityLeftovers(),
|
|
13864
|
+
remove: () => removeAntigravityLeftovers(),
|
|
13865
|
+
}));
|
|
13866
|
+
ROUTES.push(desktopViewer.route);
|
|
13037
13867
|
const toolResults = new ToolResults();
|
|
13038
13868
|
const handleRequest = async (req, res) => {
|
|
13039
13869
|
let url;
|
|
@@ -13090,7 +13920,7 @@ const handleRequest = async (req, res) => {
|
|
|
13090
13920
|
if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
|
|
13091
13921
|
return;
|
|
13092
13922
|
if (method === "GET" && path === "/.well-known/openmausbot/environment") {
|
|
13093
|
-
return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
|
|
13923
|
+
return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
|
|
13094
13924
|
}
|
|
13095
13925
|
const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
|
|
13096
13926
|
if (method === "GET" && domainCheck) {
|
|
@@ -13100,7 +13930,11 @@ const handleRequest = async (req, res) => {
|
|
|
13100
13930
|
}
|
|
13101
13931
|
// Sign in with an emailed code (server/account-signin.ts). Public like
|
|
13102
13932
|
// /api/auth/pair, JSON-only for the same reason, and counted against the
|
|
13103
|
-
// same per-source lockout so a code cannot be guessed.
|
|
13933
|
+
// same per-source lockout so a code cannot be guessed. Never on a Cloud
|
|
13934
|
+
// home: it is personal, and the owner's devices sign in through the Admin.
|
|
13935
|
+
if (CLOUD_HOME && method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
|
|
13936
|
+
return json(res, 403, { error: CLOUD_PERSONAL_REFUSAL, code: "cloud_personal" });
|
|
13937
|
+
}
|
|
13104
13938
|
if (method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
|
|
13105
13939
|
if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
|
|
13106
13940
|
return json(res, 415, { error: "send the sign-in request as JSON (content-type: application/json)" });
|
|
@@ -13170,13 +14004,27 @@ const handleRequest = async (req, res) => {
|
|
|
13170
14004
|
const wantsCookie = body?.cookie === true;
|
|
13171
14005
|
const label = typeof body?.label === "string" ? body.label : "";
|
|
13172
14006
|
const attemptId = typeof body?.attemptId === "string" ? body.attemptId : undefined;
|
|
13173
|
-
|
|
14007
|
+
// A browser sign-in (the OMB Cloud page's "Use in your browser", docs/cloud-pro.md)
|
|
14008
|
+
// redeems only a window opened for one, and only into this browser's cookie.
|
|
14009
|
+
const browser = body?.browser === true;
|
|
14010
|
+
if (browser && body?.preview === true) {
|
|
14011
|
+
// Whose Cloud it signs in to, shown before the person continues. Redeems nothing.
|
|
14012
|
+
const preview = sessions.previewBrowserSignIn(code);
|
|
14013
|
+
return preview ? json(res, 200, preview) : json(res, 401, { error: "this sign-in link has expired or was already used" });
|
|
14014
|
+
}
|
|
14015
|
+
if (browser && !wantsCookie)
|
|
14016
|
+
return json(res, 400, { error: "a browser sign-in sets this browser's cookie; send cookie: true" });
|
|
14017
|
+
const result = sessions.exchange({ code, label, attemptId, browser, source: requestSource(req), fallbackLabel: labelFromUserAgent(req.headers["user-agent"]) });
|
|
13174
14018
|
if (!result.ok) {
|
|
13175
14019
|
console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
|
|
13176
14020
|
return json(res, result.status, { error: result.error });
|
|
13177
14021
|
}
|
|
13178
|
-
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled(), sharedComputers: lendingEnabled() });
|
|
14022
|
+
const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() });
|
|
13179
14023
|
if (wantsCookie) {
|
|
14024
|
+
// A browser sign-in replaces this browser's own session here, if it had one, rather than leaving it behind.
|
|
14025
|
+
const previous = browser ? sessions.authenticate(parseCookies(req.headers.cookie).get(SESSION_COOKIE)) : null;
|
|
14026
|
+
if (previous && previous.id !== result.session.id)
|
|
14027
|
+
sessions.revoke(previous.id);
|
|
13180
14028
|
const secure = requestOrigin(req)?.startsWith("https://") === true;
|
|
13181
14029
|
res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
|
|
13182
14030
|
return json(res, 200, { session: result.session, environment });
|
|
@@ -13310,12 +14158,18 @@ const handleRequest = async (req, res) => {
|
|
|
13310
14158
|
environmentId: ENVIRONMENT_ID,
|
|
13311
14159
|
// the account behind the session, when it came from a sign-in
|
|
13312
14160
|
...(auth.session.email ? { email: auth.session.email } : {}),
|
|
14161
|
+
// whose OMB Cloud a browser sign-in signed in to
|
|
14162
|
+
...(auth.session.owner ? { owner: auth.session.owner } : {}),
|
|
13313
14163
|
// a hosted team workspace: the web UI's first run skips the
|
|
13314
14164
|
// desktop-only beats there. Absent everywhere else.
|
|
13315
14165
|
...(HOSTED_WORKSPACE ? { hosted: true } : {}),
|
|
13316
14166
|
// an OMB Cloud home: the web UI's first run is its engine
|
|
13317
14167
|
// sign-in (docs/cloud-pro.md). Absent everywhere else.
|
|
13318
14168
|
...(CLOUD_HOME ? { cloudHome: true } : {}),
|
|
14169
|
+
// a device that is not one of the owner's own on a Cloud home
|
|
14170
|
+
// writes only in conversations it opened: the composer offers
|
|
14171
|
+
// a new conversation everywhere else.
|
|
14172
|
+
...(CLOUD_HOME && !cloudOwnerSession(auth) ? { cloudGuest: true, openedThreads: threadStarters.threadsOf(actorKey(auth), 2_000) } : {}),
|
|
13319
14173
|
});
|
|
13320
14174
|
}
|
|
13321
14175
|
if (method === "POST" && path === "/api/auth/stream-ticket") {
|
|
@@ -13339,7 +14193,16 @@ const handleRequest = async (req, res) => {
|
|
|
13339
14193
|
}
|
|
13340
14194
|
const requested = body?.scopes;
|
|
13341
14195
|
const scopes = Array.isArray(requested) ? requested.filter((v) => v === "admin" || v === "client") : undefined;
|
|
13342
|
-
|
|
14196
|
+
let opened;
|
|
14197
|
+
try {
|
|
14198
|
+
opened = sessions.openPairing({ label: typeof body?.label === "string" ? body.label : undefined, scopes });
|
|
14199
|
+
}
|
|
14200
|
+
catch (error) {
|
|
14201
|
+
// A Cloud home is personal: a device paired to it has admin scope (sessions.requireAdmin).
|
|
14202
|
+
if (error.code === "cloud_personal")
|
|
14203
|
+
return json(res, 403, { error: error.message, code: "cloud_personal" });
|
|
14204
|
+
throw error;
|
|
14205
|
+
}
|
|
13343
14206
|
const origin = requestOrigin(req);
|
|
13344
14207
|
const base = publicUrl() ?? (auth.kind === "session" && origin ? origin : null);
|
|
13345
14208
|
const code = formatPairingCode(opened.code);
|
|
@@ -13702,19 +14565,31 @@ const handleRequest = async (req, res) => {
|
|
|
13702
14565
|
// Notes written from a room fewer people can see than this bot would
|
|
13703
14566
|
// carry that room's words to everyone who can see the bot.
|
|
13704
14567
|
if ((path === "/api/internal/memory" || path === "/api/internal/memory/log") && method === "POST") {
|
|
14568
|
+
// On a Cloud home a bot's memory loads into its owner's turns, and
|
|
14569
|
+
// through them can reach a lent Mac: only a conversation that is
|
|
14570
|
+
// provably the owner's may write it.
|
|
14571
|
+
if (CLOUD_HOME && cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) !== null) {
|
|
14572
|
+
return json(res, 403, { error: "Notes can only be saved from a conversation that only the owner of this Cloud has written in." });
|
|
14573
|
+
}
|
|
14574
|
+
if (internalSender.memoryEnabled === false) {
|
|
14575
|
+
return json(res, 403, { error: "Memory is disabled for this bot." });
|
|
14576
|
+
}
|
|
13705
14577
|
const room = store.groupByThread(internalCapability.threadId);
|
|
13706
14578
|
if (room && !roomFeedsBot(room, internalSender)) {
|
|
13707
14579
|
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." });
|
|
13708
14580
|
}
|
|
13709
14581
|
}
|
|
14582
|
+
// On a Cloud home only the owner's conversations get here (above): the
|
|
14583
|
+
// write is theirs (server/lending-memory.ts).
|
|
14584
|
+
const ownersWrite = (write) => lendingMemory ? lendingMemory.trustedWrite(internalSender.id, write) : write();
|
|
13710
14585
|
if (method === "POST" && path === "/api/internal/memory") {
|
|
13711
14586
|
const body = await readInternalBody();
|
|
13712
|
-
const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() });
|
|
14587
|
+
const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
|
|
13713
14588
|
return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
|
|
13714
14589
|
}
|
|
13715
14590
|
if (method === "POST" && path === "/api/internal/memory/log") {
|
|
13716
14591
|
const body = await readInternalBody();
|
|
13717
|
-
const result = appendMemoryLog(internalSender.id, body.text, { source: memorySource() });
|
|
14592
|
+
const result = ownersWrite(() => appendMemoryLog(internalSender.id, body.text, { source: memorySource() }));
|
|
13718
14593
|
return json(res, result.ok ? 200 : 400, result);
|
|
13719
14594
|
}
|
|
13720
14595
|
if (method === "POST" && path === "/api/internal/browser/mcp") {
|
|
@@ -13762,8 +14637,8 @@ const handleRequest = async (req, res) => {
|
|
|
13762
14637
|
if (method === "GET" && path === "/api/internal/shared-computers" && lendingEnabled()) {
|
|
13763
14638
|
const principal = sharedComputerPrincipal(internalCapability);
|
|
13764
14639
|
// Say why an owner's own conversation sees no Mac, so the bot can tell them.
|
|
13765
|
-
const
|
|
13766
|
-
|
|
14640
|
+
const why = CLOUD_HOME && !principal && sharedComputers.status(CLOUD_HOME_LENDER).length > 0 ? cloudLendingRefusal(internalCapability) : null;
|
|
14641
|
+
const notice = why === "someone-else" ? { unavailable: LENDING_SOMEONE_ELSE } : why === "memory-changed" ? { unavailable: LENDING_MEMORY_CHANGED } : {};
|
|
13767
14642
|
return json(res, 200, { computers: sharedComputers.list(principal), ...notice });
|
|
13768
14643
|
}
|
|
13769
14644
|
if (method === "POST" && path === "/api/internal/shared-computers" && lendingEnabled()) {
|
|
@@ -13777,8 +14652,9 @@ const handleRequest = async (req, res) => {
|
|
|
13777
14652
|
return json(res, 403, { error: "This bot has no computer. Change its Computer setting to use a shared computer's apps and screen." });
|
|
13778
14653
|
}
|
|
13779
14654
|
const principal = sharedComputerPrincipal(internalCapability);
|
|
13780
|
-
|
|
13781
|
-
|
|
14655
|
+
const why = CLOUD_HOME && !principal ? cloudLendingRefusal(internalCapability) : null;
|
|
14656
|
+
if (why === "someone-else" || why === "memory-changed") {
|
|
14657
|
+
return json(res, 403, { error: why === "someone-else" ? LENDING_SOMEONE_ELSE : LENDING_MEMORY_CHANGED });
|
|
13782
14658
|
}
|
|
13783
14659
|
return json(res, 200, { result: await sharedComputers.request(parsed.data, principal, () => lendingEnabled() && internalCapabilityIsActive(internalCapability)) });
|
|
13784
14660
|
}
|
|
@@ -13837,7 +14713,13 @@ const handleRequest = async (req, res) => {
|
|
|
13837
14713
|
return "closed";
|
|
13838
14714
|
return "idle";
|
|
13839
14715
|
};
|
|
14716
|
+
// On a Cloud home a turn that may use the lent Mac sees only the
|
|
14717
|
+
// conversations the owner alone opened and wrote in: a title is words
|
|
14718
|
+
// too (cloudLendingView).
|
|
14719
|
+
const shown = cloudLendingView(internalCapability) ? cloudOwnerOnlyThread : () => true;
|
|
13840
14720
|
for (const task of store.tasks(from.id)) {
|
|
14721
|
+
if (!shown(task.threadId))
|
|
14722
|
+
continue;
|
|
13841
14723
|
rows.push({
|
|
13842
14724
|
threadId: task.threadId, botId: from.id, botName: from.name, title: task.title,
|
|
13843
14725
|
state: stateOf(from, task), unread: task.unread === true, openedAt: task.openedBy?.at ?? task.createdAt,
|
|
@@ -13846,7 +14728,7 @@ const handleRequest = async (req, res) => {
|
|
|
13846
14728
|
}
|
|
13847
14729
|
for (const peer of reachablePeers(store.bots, from)) {
|
|
13848
14730
|
for (const task of store.tasks(peer.id)) {
|
|
13849
|
-
if (task.openedBy?.botId !== from.id)
|
|
14731
|
+
if (task.openedBy?.botId !== from.id || !shown(task.threadId))
|
|
13850
14732
|
continue;
|
|
13851
14733
|
rows.push({
|
|
13852
14734
|
threadId: task.threadId, botId: peer.id, botName: peer.name, title: task.title,
|
|
@@ -14079,6 +14961,12 @@ const handleRequest = async (req, res) => {
|
|
|
14079
14961
|
: body.action === "update"
|
|
14080
14962
|
? { action: body.action, routineId: body.routineId, changes: body.changes, forBot }
|
|
14081
14963
|
: { action: body.action, routineId: body.routineId, forBot };
|
|
14964
|
+
// Whether the routine a change is for is the owner's, and whether this
|
|
14965
|
+
// turn acts for the owner (their own conversation, or a run of a
|
|
14966
|
+
// routine that is theirs), both before the change.
|
|
14967
|
+
const wasOwners = proposedInput.action !== "create" && typeof proposedInput.routineId === "string" ? cloudRoutineIsOwners(proposedInput.routineId) : false;
|
|
14968
|
+
const run = activeRoutineRunForThread(fromThreadId);
|
|
14969
|
+
const ownersTurn = cloudOwnerOnlyThread(fromThreadId) || cloudRoutineRunIsOwners(run, from.id, fromThreadId);
|
|
14082
14970
|
const proposed = await routineRequests.submit({
|
|
14083
14971
|
botId: from.id,
|
|
14084
14972
|
threadId: fromThreadId,
|
|
@@ -14087,6 +14975,17 @@ const handleRequest = async (req, res) => {
|
|
|
14087
14975
|
canCommit: () => internalCapabilityIsActive(internalCapability),
|
|
14088
14976
|
});
|
|
14089
14977
|
const proposedCard = store.messagesFor(fromThreadId).find((message) => message.id === proposed.messageId)?.card;
|
|
14978
|
+
// Applied at once in a Full-access conversation. In the owner's own (one
|
|
14979
|
+
// only they provably wrote in) it is theirs, as if they had approved
|
|
14980
|
+
// the card. From anywhere else, a routine it created or changed is no
|
|
14981
|
+
// longer the owner's, like anyone else's edit (the routine PATCH).
|
|
14982
|
+
const applied = CLOUD_HOME && proposed.state === "applied" && "result" in proposed ? proposed.result : undefined;
|
|
14983
|
+
if (applied?.resultId && ownersTurn)
|
|
14984
|
+
cloudOwnerApplied(applied.action, applied.resultId, wasOwners);
|
|
14985
|
+
else if (applied?.resultId && (applied.action === "create" || applied.action === "update")) {
|
|
14986
|
+
cloudRoutineAuthors?.forget(applied.resultId);
|
|
14987
|
+
cloudRoutineAuthors?.wrote(applied.resultId, CLOUD_NOBODY_KEY);
|
|
14988
|
+
}
|
|
14090
14989
|
appendDecision(DATA_DIR, {
|
|
14091
14990
|
threadId: fromThreadId,
|
|
14092
14991
|
requestId: proposed.requestId,
|
|
@@ -14305,7 +15204,7 @@ const handleRequest = async (req, res) => {
|
|
|
14305
15204
|
if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
|
|
14306
15205
|
return json(res, 400, { error: "scope must be all, conversations, or memory" });
|
|
14307
15206
|
}
|
|
14308
|
-
const memoryHits = scope === "conversations" || !q ? [] : searchMemoryFiles(from.id, q, limit);
|
|
15207
|
+
const memoryHits = scope === "conversations" || !q || from.memoryEnabled === false ? [] : searchMemoryFiles(from.id, q, limit);
|
|
14309
15208
|
if (scope === "memory")
|
|
14310
15209
|
return json(res, 200, { hits: [], memoryHits });
|
|
14311
15210
|
// Own threads: the bot's main chat and tasks, and the rooms it is a
|
|
@@ -14321,7 +15220,10 @@ const handleRequest = async (req, res) => {
|
|
|
14321
15220
|
for (const task of group.tasks ?? [])
|
|
14322
15221
|
roomByThread.set(task.threadId, group);
|
|
14323
15222
|
}
|
|
14324
|
-
const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])]
|
|
15223
|
+
const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])]
|
|
15224
|
+
// On a Cloud home a turn that may use the lent Mac searches only the
|
|
15225
|
+
// conversations the owner alone opened and wrote in.
|
|
15226
|
+
.filter(cloudLendingView(internalCapability) ? cloudOwnerOnlyThread : () => true);
|
|
14325
15227
|
// A room is the only place a recall can be a disclosure, and only a
|
|
14326
15228
|
// private chat is one: in a 1:1 the user already owns every thread
|
|
14327
15229
|
// the bot can reach, and a room's lines were said in the open.
|
|
@@ -14360,7 +15262,8 @@ const handleRequest = async (req, res) => {
|
|
|
14360
15262
|
const messageId = String(url.searchParams.get("messageId") ?? "").trim();
|
|
14361
15263
|
if (!threadId || !messageId)
|
|
14362
15264
|
return json(res, 400, { error: "threadId and messageId are required" });
|
|
14363
|
-
const own = threadId === from.threadId || Boolean(store.taskByThread(from.id, threadId))
|
|
15265
|
+
const own = (threadId === from.threadId || Boolean(store.taskByThread(from.id, threadId))) &&
|
|
15266
|
+
(!cloudLendingView(internalCapability) || cloudOwnerOnlyThread(threadId));
|
|
14364
15267
|
const message = own ? readMessageText(threadId, messageId) : null;
|
|
14365
15268
|
if (!message)
|
|
14366
15269
|
return json(res, 404, { error: "no such message in your conversations" });
|
|
@@ -14530,7 +15433,12 @@ const handleRequest = async (req, res) => {
|
|
|
14530
15433
|
// can check next turn. If the ledger refuses (cap/depth), fall back
|
|
14531
15434
|
// to the plain busy bounce rather than dropping the refusal reason.
|
|
14532
15435
|
const queueBusyFallback = (approvalAlreadyGranted = false) => {
|
|
14533
|
-
const
|
|
15436
|
+
const guestThread = guestWorkThread(from, target, fromThreadId, message);
|
|
15437
|
+
if (guestThread === null)
|
|
15438
|
+
return json(res, 500, { error: "couldn't open a conversation for that work" });
|
|
15439
|
+
const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
|
|
15440
|
+
if ((queued.result !== "ok" || !queued.id) && guestThread)
|
|
15441
|
+
store.deleteTask(toBotId, guestThread);
|
|
14534
15442
|
if (queued.result !== "ok" || !queued.id)
|
|
14535
15443
|
return json(res, 200, { busy: true, receipt: peerDeliveryReceipt({
|
|
14536
15444
|
botId: toBotId, botName: target.name, outcome: "failed",
|
|
@@ -14558,6 +15466,10 @@ const handleRequest = async (req, res) => {
|
|
|
14558
15466
|
const tryAsideDelivery = async () => {
|
|
14559
15467
|
if (peerReviewRequired(from, fromThreadId))
|
|
14560
15468
|
return null;
|
|
15469
|
+
// A guest's request never folds into a turn running in the
|
|
15470
|
+
// owner's conversation; it waits for a confined one of its own.
|
|
15471
|
+
if (cloudGuestDriven(fromThreadId))
|
|
15472
|
+
return null;
|
|
14561
15473
|
// Busy elsewhere (room turn, sibling thread): the thread this ask
|
|
14562
15474
|
// targets is not the running one, so there is nothing to fold
|
|
14563
15475
|
// into here — the queue lane keeps it.
|
|
@@ -14664,7 +15576,10 @@ const handleRequest = async (req, res) => {
|
|
|
14664
15576
|
delivery: "ask_bot",
|
|
14665
15577
|
unattended: isUnattended(currentFrom.id, fromThreadId),
|
|
14666
15578
|
});
|
|
14667
|
-
const
|
|
15579
|
+
const guestThread = guestWorkThread(currentFrom, currentTarget, fromThreadId, message);
|
|
15580
|
+
if (guestThread === null)
|
|
15581
|
+
return json(res, 500, { error: "couldn't open a conversation for that work" });
|
|
15582
|
+
const targetThreadId = guestThread ?? currentTarget.threadId;
|
|
14668
15583
|
const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId, fromThreadId, targetThreadId);
|
|
14669
15584
|
requireActiveInternalCapability();
|
|
14670
15585
|
const replySender = store.bot(fromBotId);
|
|
@@ -14876,7 +15791,12 @@ const handleRequest = async (req, res) => {
|
|
|
14876
15791
|
if (!connectorThread(from.id, fromThreadId)) {
|
|
14877
15792
|
return json(res, 403, { error: "source thread does not belong to sender" });
|
|
14878
15793
|
}
|
|
14879
|
-
const
|
|
15794
|
+
const guestThread = guestWorkThread(from, target, fromThreadId, reason ?? message);
|
|
15795
|
+
if (guestThread === null)
|
|
15796
|
+
return json(res, 500, { error: "couldn't open a conversation for that work" });
|
|
15797
|
+
const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
|
|
15798
|
+
if ((queued.result !== "ok" || !queued.id) && guestThread)
|
|
15799
|
+
store.deleteTask(toBotId, guestThread);
|
|
14880
15800
|
if (queued.result !== "ok" || !queued.id) {
|
|
14881
15801
|
// the agent reads this string — a bare enum ("too_deep") tells it
|
|
14882
15802
|
// nothing about what to do instead
|
|
@@ -14944,7 +15864,7 @@ const handleRequest = async (req, res) => {
|
|
|
14944
15864
|
})).filter(g => g.members.length);
|
|
14945
15865
|
return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
|
|
14946
15866
|
bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
|
|
14947
|
-
rooms, note: "Without group_id: use this room when in a room, otherwise
|
|
15867
|
+
rooms, note: "Without group_id: use this room when in a room, otherwise each distinct assignment starts a fresh thread for that teammate. Give a self-contained brief. Each bot uses its own environment and permissions. Files are not transferred: pass absolute paths only when accessible to the recipient, otherwise pass the content." });
|
|
14948
15868
|
}
|
|
14949
15869
|
if (method === "POST" && path === "/api/internal/coordinate-bots") {
|
|
14950
15870
|
const parsed = z.object({
|
|
@@ -15021,39 +15941,21 @@ const handleRequest = async (req, res) => {
|
|
|
15021
15941
|
try {
|
|
15022
15942
|
requireActiveInternalCapability();
|
|
15023
15943
|
if (!destination) {
|
|
15024
|
-
|
|
15025
|
-
|
|
15026
|
-
// request key, or the thread the person has selected there.
|
|
15027
|
-
const resolved = store.resolvePairConversation(internalSender, target.botId, {
|
|
15028
|
-
label: parsed.data.label,
|
|
15029
|
-
// "Still working" exactly as close_thread reads it: a
|
|
15030
|
-
// running turn, a queued one, or coordinated work already
|
|
15031
|
-
// addressed at that thread.
|
|
15032
|
-
working: threadId => threadBusy(target.botId, threadId)
|
|
15033
|
-
|| queuedThreadPosition(target.botId, threadId) !== null
|
|
15034
|
-
|| roomHandoffs.activeDirect(threadId),
|
|
15035
|
-
});
|
|
15036
|
-
if (!resolved)
|
|
15944
|
+
const task = store.createTask(target.botId, `@${internalSender.name}${parsed.data.label ? ` · ${parsed.data.label}` : " · work"}`, false, undefined, { botId: internalSender.id, name: internalSender.name, kind: "work", at: Date.now() });
|
|
15945
|
+
if (!task)
|
|
15037
15946
|
throw new Error("The recipient no longer exists");
|
|
15038
|
-
target.threadId =
|
|
15039
|
-
if (resolved.created)
|
|
15040
|
-
createdThread = resolved.task.threadId;
|
|
15041
|
-
// A work thread carries one assignment: it is for the person
|
|
15042
|
-
// this coordination serves. The durable pair conversation is
|
|
15043
|
-
// shared by every assignment between two bots, so it names nobody.
|
|
15044
|
-
if (resolved.created && resolved.task.openedBy?.kind === "work")
|
|
15045
|
-
threadStarters.set(resolved.task.threadId, threadPersonKey(address.threadId));
|
|
15947
|
+
target.threadId = createdThread = task.threadId;
|
|
15046
15948
|
if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
|
|
15047
15949
|
grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
|
|
15048
15950
|
}
|
|
15049
15951
|
}
|
|
15050
15952
|
const { node, duplicate } = roomHandoffs.enqueue(address, internalCapability.generation, internalCapability.roomHandoffId, target, parsed.data.requestKey + ":" + target.botId, parsed.data.message, approvalGranted, parsed.data.rework, [...store.messagesFor(address.threadId)].reverse().find(m => m.role === "user" && m.kind === "text")?.text ?? "", destination ? parsed.data.requestKey : undefined);
|
|
15051
|
-
// A
|
|
15052
|
-
|
|
15053
|
-
// conversation was busy with that very request) goes back
|
|
15054
|
-
// before anyone sees a row that leads nowhere.
|
|
15055
|
-
if (duplicate && createdThread && createdThread !== node.threadId)
|
|
15953
|
+
// A retry keeps its original task; discard the speculative row.
|
|
15954
|
+
if (duplicate && createdThread && createdThread !== node.threadId) {
|
|
15056
15955
|
store.deleteTask(target.botId, createdThread);
|
|
15956
|
+
}
|
|
15957
|
+
if (!duplicate && createdThread)
|
|
15958
|
+
threadStarters.set(createdThread, openerFrom(address.threadId));
|
|
15057
15959
|
createdThread = undefined; // The durable coordinator now owns this task.
|
|
15058
15960
|
accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
|
|
15059
15961
|
// A duplicate request_key lands on the node enqueue already
|
|
@@ -15093,8 +15995,9 @@ const handleRequest = async (req, res) => {
|
|
|
15093
15995
|
}
|
|
15094
15996
|
}
|
|
15095
15997
|
catch (error) {
|
|
15096
|
-
if (createdThread)
|
|
15998
|
+
if (createdThread) {
|
|
15097
15999
|
store.deleteTask(target.botId, createdThread);
|
|
16000
|
+
}
|
|
15098
16001
|
const said = error instanceof Error ? error.message : String(error);
|
|
15099
16002
|
errors.push({ botId: target.botId, error: said });
|
|
15100
16003
|
receipts.push(peerDeliveryReceipt({
|
|
@@ -15337,10 +16240,11 @@ const handleRequest = async (req, res) => {
|
|
|
15337
16240
|
}
|
|
15338
16241
|
const sourceTitle = owner.group ? owner.group.name : (store.taskByThread(from.id, fromThreadId)?.title ?? "");
|
|
15339
16242
|
if (target.id === from.id) {
|
|
15340
|
-
const
|
|
16243
|
+
const inherited = parentThreadModel(from.id, fromThreadId);
|
|
16244
|
+
const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() }, inherited.approvalMode, inherited.modelSelection);
|
|
15341
16245
|
if (!task)
|
|
15342
16246
|
return json(res, 500, { error: "couldn't create that thread" });
|
|
15343
|
-
threadStarters.set(task.threadId,
|
|
16247
|
+
threadStarters.set(task.threadId, openerFrom(fromThreadId));
|
|
15344
16248
|
internalCapability.openedThreads += 1;
|
|
15345
16249
|
const chip = {
|
|
15346
16250
|
role: "bot",
|
|
@@ -15383,7 +16287,7 @@ const handleRequest = async (req, res) => {
|
|
|
15383
16287
|
if (!task)
|
|
15384
16288
|
return json(res, 500, { error: "couldn't create that thread" });
|
|
15385
16289
|
// The work is still for the person whose request the opener is on.
|
|
15386
|
-
threadStarters.set(task.threadId,
|
|
16290
|
+
threadStarters.set(task.threadId, openerFrom(fromThreadId));
|
|
15387
16291
|
if (delegatedFullAccess(from, fromThreadId, target))
|
|
15388
16292
|
grantDelegatedFullAccess(from, target, task.threadId);
|
|
15389
16293
|
const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
|
|
@@ -15932,16 +16836,25 @@ const handleRequest = async (req, res) => {
|
|
|
15932
16836
|
.filter((run) => routineVisible(run, visible)),
|
|
15933
16837
|
});
|
|
15934
16838
|
}
|
|
16839
|
+
// On a Cloud home a routine reports only where its writer may write: a
|
|
16840
|
+
// guest's into a conversation the guest started, never the owner's.
|
|
16841
|
+
const routineResultsRefusal = (body) => body && typeof body === "object" && typeof body.resultsThreadId === "string"
|
|
16842
|
+
? cloudThreadRefusal(auth, body.resultsThreadId) : null;
|
|
15935
16843
|
if (path === "/api/routines" && method === "POST") {
|
|
15936
16844
|
const body = await readBody(req);
|
|
15937
16845
|
const hidden = hiddenRoutineTarget(body, visible);
|
|
15938
16846
|
if (hidden)
|
|
15939
16847
|
return json(res, 404, { error: hidden });
|
|
15940
|
-
const
|
|
16848
|
+
const notYours = routineResultsRefusal(body);
|
|
16849
|
+
if (notYours)
|
|
16850
|
+
return json(res, 403, { error: notYours });
|
|
16851
|
+
const writer = auth.kind === "session" ? actorKey(auth) : CLOUD_NOBODY_KEY;
|
|
16852
|
+
const routine = withRoutineWriter(writer, () => routines.create(body));
|
|
15941
16853
|
// On a Cloud home, a routine the owner writes from their own device may
|
|
15942
16854
|
// use their lent Mac when it runs (server/cloud-lending.ts).
|
|
15943
16855
|
if (cloudRoutineAuthors && cloudOwnerSession(auth))
|
|
15944
16856
|
cloudRoutineAuthors.record(routine.id, routine);
|
|
16857
|
+
cloudRoutineAuthors?.wrote(routine.id, writer);
|
|
15945
16858
|
return json(res, 201, { routine });
|
|
15946
16859
|
}
|
|
15947
16860
|
// The desktop shell polls this to decide whether to hold the computer
|
|
@@ -15962,9 +16875,27 @@ const handleRequest = async (req, res) => {
|
|
|
15962
16875
|
const hidden = hiddenRoutineTarget(body, visible);
|
|
15963
16876
|
if (hidden)
|
|
15964
16877
|
return json(res, 404, { error: hidden });
|
|
16878
|
+
const notYours = routineResultsRefusal(body);
|
|
16879
|
+
if (notYours)
|
|
16880
|
+
return json(res, 403, { error: notYours });
|
|
15965
16881
|
const before = cloudRoutineAuthors ? routines.listRoutines().find((candidate) => candidate.id === routineMatch[1]) : undefined;
|
|
15966
16882
|
const wasOwners = Boolean(before && cloudRoutineAuthors?.authored(before.id, before));
|
|
15967
|
-
const
|
|
16883
|
+
const previous = cloudRoutineAuthors?.writer(routineMatch[1]);
|
|
16884
|
+
// Who this edit leaves as the routine's writer: the owner when the
|
|
16885
|
+
// routine stays (or becomes) theirs (their fingerprint, or already
|
|
16886
|
+
// theirs as its writer: a template, a restore), a guest when a guest
|
|
16887
|
+
// edits it, else whoever wrote it before.
|
|
16888
|
+
// An owner writer is proof only for a routine that never had the
|
|
16889
|
+
// owner's fingerprint (a template, a restore, one the settlement named):
|
|
16890
|
+
// a fingerprint that no longer matches means someone else changed what
|
|
16891
|
+
// it runs, and only the owner rewriting its instructions renews it.
|
|
16892
|
+
const ownerWriterOnly = CLOUD_OWNER_KEY !== null && previous === CLOUD_OWNER_KEY && !cloudRoutineAuthors?.recorded(routineMatch[1]);
|
|
16893
|
+
const ownerAuthors = cloudOwnerSession(auth) && (wasOwners || ownerWriterOnly ||
|
|
16894
|
+
(body && typeof body === "object" && "prompt" in body));
|
|
16895
|
+
const writer = ownerAuthors && CLOUD_OWNER_KEY ? CLOUD_OWNER_KEY
|
|
16896
|
+
: auth.kind === "session" && !cloudOwnerSession(auth) ? actorKey(auth)
|
|
16897
|
+
: previous && !cloudOwnerPerson(previous) ? previous : CLOUD_NOBODY_KEY;
|
|
16898
|
+
const routine = withRoutineWriter(writer, () => routines.update(routineMatch[1], body));
|
|
15968
16899
|
// The owner's own edit keeps (or, when it rewrites the instructions,
|
|
15969
16900
|
// makes) the routine theirs. Anyone else's edit, of any field (its
|
|
15970
16901
|
// schedule, where its results go, whether it is on), makes it no longer
|
|
@@ -15972,15 +16903,16 @@ const handleRequest = async (req, res) => {
|
|
|
15972
16903
|
if (routine && cloudRoutineAuthors) {
|
|
15973
16904
|
if (!cloudOwnerSession(auth))
|
|
15974
16905
|
cloudRoutineAuthors.forget(routine.id);
|
|
15975
|
-
else if (
|
|
16906
|
+
else if (ownerAuthors)
|
|
15976
16907
|
cloudRoutineAuthors.record(routine.id, routine);
|
|
16908
|
+
cloudRoutineAuthors.wrote(routine.id, writer);
|
|
15977
16909
|
}
|
|
15978
16910
|
return routine ? json(res, 200, { routine }) : json(res, 404, { error: "no such routine" });
|
|
15979
16911
|
}
|
|
15980
16912
|
if (routineMatch && method === "DELETE") {
|
|
15981
16913
|
const removed = routines.remove(routineMatch[1]);
|
|
15982
16914
|
if (removed)
|
|
15983
|
-
cloudRoutineAuthors?.
|
|
16915
|
+
cloudRoutineAuthors?.remove(routineMatch[1]);
|
|
15984
16916
|
return removed
|
|
15985
16917
|
? json(res, 200, { ok: true })
|
|
15986
16918
|
: json(res, 404, { error: "no such routine" });
|
|
@@ -16742,6 +17674,9 @@ const handleRequest = async (req, res) => {
|
|
|
16742
17674
|
if (hiddenMember)
|
|
16743
17675
|
return json(res, 400, { error: `unknown channel member: ${String(hiddenMember)}` });
|
|
16744
17676
|
const group = createChannel(body);
|
|
17677
|
+
// On a Cloud home whoever opens a room names it (cloudThreadRefusal).
|
|
17678
|
+
if (CLOUD_HOME && auth.kind === "session")
|
|
17679
|
+
threadStarters.set(group.threadId, actorKey(auth));
|
|
16745
17680
|
return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
|
|
16746
17681
|
}
|
|
16747
17682
|
if (method === "POST" && path === "/api/teams/export") {
|
|
@@ -17209,7 +18144,7 @@ const handleRequest = async (req, res) => {
|
|
|
17209
18144
|
return json(res, 500, { error: "couldn't create that task" });
|
|
17210
18145
|
// Who opened it decides who may answer its cards on a shared workspace.
|
|
17211
18146
|
if (auth.kind === "session")
|
|
17212
|
-
threadStarters.set(task.threadId,
|
|
18147
|
+
threadStarters.set(task.threadId, actorKey(auth));
|
|
17213
18148
|
const fresh = groupWithThread(store.group(group.id));
|
|
17214
18149
|
broadcast({ kind: "group", group: fresh });
|
|
17215
18150
|
return json(res, 201, { group: fresh, task });
|
|
@@ -17286,6 +18221,9 @@ const handleRequest = async (req, res) => {
|
|
|
17286
18221
|
// without the old handler turning a missing title into "Untitled".
|
|
17287
18222
|
// That echo is not a rename. A real rename stays blocked while working.
|
|
17288
18223
|
const titleChange = renaming && body.title !== existing.title;
|
|
18224
|
+
const notYours = titleChange ? cloudThreadRefusal(auth, m[2]) : null;
|
|
18225
|
+
if (notYours)
|
|
18226
|
+
return json(res, 403, { error: notYours });
|
|
17289
18227
|
if (channelTaskBlocked(group) && !(pinning && !titleChange)) {
|
|
17290
18228
|
return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
|
|
17291
18229
|
}
|
|
@@ -17318,6 +18256,9 @@ const handleRequest = async (req, res) => {
|
|
|
17318
18256
|
}
|
|
17319
18257
|
if (!store.groupTaskByThread(group.id, m[2]))
|
|
17320
18258
|
return json(res, 404, { error: "no such channel task" });
|
|
18259
|
+
const notYours = cloudThreadRefusal(auth, m[2]);
|
|
18260
|
+
if (notYours)
|
|
18261
|
+
return json(res, 403, { error: notYours });
|
|
17321
18262
|
const stagedSkillCleanups = stagedSkillCleanupsForThread(m[2]);
|
|
17322
18263
|
lastReply.delete(m[2]);
|
|
17323
18264
|
cancelTeamSetupResumesForThread(m[2]);
|
|
@@ -17339,6 +18280,14 @@ const handleRequest = async (req, res) => {
|
|
|
17339
18280
|
if (field)
|
|
17340
18281
|
return json(res, 403, { error: `forbidden: this session may rename or mark a room, not change "${field}" (needs the admin scope)` });
|
|
17341
18282
|
}
|
|
18283
|
+
// On a Cloud home a room's name and bulletin are its opener's (or the owner's).
|
|
18284
|
+
const renamed = store.group(m[1]);
|
|
18285
|
+
if (renamed && body && typeof body === "object" &&
|
|
18286
|
+
((body.name !== undefined && body.name !== renamed.name) || (body.bulletin !== undefined && body.bulletin !== (renamed.bulletin ?? "")))) {
|
|
18287
|
+
const notYours = cloudThreadRefusal(auth, renamed.threadId);
|
|
18288
|
+
if (notYours)
|
|
18289
|
+
return json(res, 403, { error: notYours });
|
|
18290
|
+
}
|
|
17342
18291
|
const group = updateChannel(m[1], body);
|
|
17343
18292
|
return json(res, 200, { group: publicGroupState(group) });
|
|
17344
18293
|
}
|
|
@@ -17963,9 +18912,14 @@ const handleRequest = async (req, res) => {
|
|
|
17963
18912
|
throw Object.assign(new Error(enabled.error), { status: 422 });
|
|
17964
18913
|
}
|
|
17965
18914
|
}
|
|
18915
|
+
// On a Cloud home only the owner's devices create bots: its routines
|
|
18916
|
+
// (and the conversations they report into) are theirs.
|
|
18917
|
+
const ownersRoutines = CLOUD_OWNER_KEY && auth.kind === "session" && cloudOwnerSession(auth) ? CLOUD_OWNER_KEY : null;
|
|
17966
18918
|
for (const routine of template.routines) {
|
|
17967
18919
|
const created = routines.create({ ...routine, botId: bot.id, enabled: false });
|
|
17968
18920
|
createdRoutines.push({ id: created.id, enabled: routine.enabled !== false });
|
|
18921
|
+
if (ownersRoutines)
|
|
18922
|
+
cloudRoutineAuthors?.wrote(created.id, ownersRoutines);
|
|
17969
18923
|
}
|
|
17970
18924
|
}
|
|
17971
18925
|
catch (error) {
|
|
@@ -18051,6 +19005,17 @@ const handleRequest = async (req, res) => {
|
|
|
18051
19005
|
return json(res, 400, { error: "avatarUrl must reference an existing stored image" });
|
|
18052
19006
|
}
|
|
18053
19007
|
const existingBot = store.bot(m[1]);
|
|
19008
|
+
// On a Cloud home a bot's name, title, description and standing
|
|
19009
|
+
// instructions ride every one of the owner's turns (and a lent Mac):
|
|
19010
|
+
// only the owner's own devices change them. Anyone else keeps the
|
|
19011
|
+
// picture and the voice. Resending an unchanged value is not a change.
|
|
19012
|
+
if (CLOUD_HOME && existingBot && !cloudOwnerSession(auth)) {
|
|
19013
|
+
const changed = CLOUD_OWNER_PROFILE_FIELDS.find((field) => parsed.patch[field] !== undefined && (field === "notifications"
|
|
19014
|
+
? (parsed.patch.notifications !== false) !== (existingBot.notifications !== false)
|
|
19015
|
+
: parsed.patch[field] !== (existingBot[field] ?? "")));
|
|
19016
|
+
if (changed)
|
|
19017
|
+
return json(res, 403, { error: "On this Cloud only its owner can change a bot's name, title, description, instructions or notifications." });
|
|
19018
|
+
}
|
|
18054
19019
|
const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
|
|
18055
19020
|
const bot = store.patchBotProfile(m[1], parsed.patch);
|
|
18056
19021
|
if (!bot)
|
|
@@ -18118,7 +19083,7 @@ const handleRequest = async (req, res) => {
|
|
|
18118
19083
|
m = path.match(/^\/api\/bots\/([\w-]+)\/command-allowlist(?:\/([\w-]+))?$/);
|
|
18119
19084
|
if (m && ["GET", "POST", "DELETE"].includes(method)) {
|
|
18120
19085
|
if (!canManageCommandAllowlist(auth))
|
|
18121
|
-
return json(res, 403, { error: "Only the
|
|
19086
|
+
return json(res, 403, { error: "Only the owner or an admin can manage command permissions." });
|
|
18122
19087
|
const bot = requestedTaskBot(m[1], url.searchParams.get("threadId") ?? undefined);
|
|
18123
19088
|
if (method === "GET" && !m[2])
|
|
18124
19089
|
return json(res, 200, commandAllowlistResponse(bot));
|
|
@@ -18304,6 +19269,15 @@ const handleRequest = async (req, res) => {
|
|
|
18304
19269
|
return json(res, 400, { error: "voiceNotes must be true or false" });
|
|
18305
19270
|
patch.voiceNotes = body.voiceNotes;
|
|
18306
19271
|
}
|
|
19272
|
+
if (body.memoryEnabled !== undefined) {
|
|
19273
|
+
if (typeof body.memoryEnabled !== "boolean")
|
|
19274
|
+
return json(res, 400, { error: "memoryEnabled must be true or false" });
|
|
19275
|
+
if (existingBot && (existingBot.busy || activeGroupTurnForBot(existingBot.id)) &&
|
|
19276
|
+
body.memoryEnabled !== (existingBot.memoryEnabled !== false)) {
|
|
19277
|
+
return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
|
|
19278
|
+
}
|
|
19279
|
+
patch.memoryEnabled = body.memoryEnabled;
|
|
19280
|
+
}
|
|
18307
19281
|
// which of those apps' tools this bot may call (connector grants 1/5:
|
|
18308
19282
|
// data model only — enforcement lands with slice 2). null returns the
|
|
18309
19283
|
// bot to boolean-only legacy behavior; {} means no tools.
|
|
@@ -18593,7 +19567,7 @@ const handleRequest = async (req, res) => {
|
|
|
18593
19567
|
// come from local scripts only when all bots are idle; paired owners
|
|
18594
19568
|
// and the packaged desktop's private capability can grant it mid-turn.
|
|
18595
19569
|
if (loosened.includes("managedSections") && auth.kind === "loopback" && !DESKTOP_MANAGED && store.bots.some(bot => bot.busy)) {
|
|
18596
|
-
return json(res, 409, { error: "Stop running bots before granting access to another team, or use the desktop app or a paired
|
|
19570
|
+
return json(res, 409, { error: "Stop running bots before granting access to another team, or use the desktop app or a paired Full-access session." });
|
|
18597
19571
|
}
|
|
18598
19572
|
if (body.peers !== undefined && Array.isArray(existingBot?.peers)) {
|
|
18599
19573
|
const nextPeers = patch.peers;
|
|
@@ -18640,6 +19614,13 @@ const handleRequest = async (req, res) => {
|
|
|
18640
19614
|
sectionKey(existingBot?.section) !== sectionKey(section);
|
|
18641
19615
|
let bot;
|
|
18642
19616
|
const freshBrowserBot = store.bot(m[1]);
|
|
19617
|
+
// Connector validation and runtime revocation can yield after the first
|
|
19618
|
+
// memory check. Keep the same idle-only transition at the final commit.
|
|
19619
|
+
if (freshBrowserBot && body.memoryEnabled !== undefined &&
|
|
19620
|
+
body.memoryEnabled !== (freshBrowserBot.memoryEnabled !== false) &&
|
|
19621
|
+
(freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
|
|
19622
|
+
return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
|
|
19623
|
+
}
|
|
18643
19624
|
// Custom servers are process configuration: a running turn cannot
|
|
18644
19625
|
// unmount them. Recheck after any awaited runtime revocation above.
|
|
18645
19626
|
if (body.mcpServers !== undefined) {
|
|
@@ -18695,7 +19676,7 @@ const handleRequest = async (req, res) => {
|
|
|
18695
19676
|
}
|
|
18696
19677
|
if (!bot)
|
|
18697
19678
|
return json(res, 404, { error: "no such bot" });
|
|
18698
|
-
if (body.memoryUpkeep === false)
|
|
19679
|
+
if (body.memoryUpkeep === false || body.memoryEnabled === false)
|
|
18699
19680
|
memoryUpkeep.dropBot(bot.id);
|
|
18700
19681
|
// A defined Works on is the newest explicit choice: this bot's
|
|
18701
19682
|
// auto-recorded pins that now point elsewhere give way immediately, so
|
|
@@ -19165,6 +20146,9 @@ const handleRequest = async (req, res) => {
|
|
|
19165
20146
|
// receipt after a task switch, while a genuinely new send still has to
|
|
19166
20147
|
// target the task that is active now.
|
|
19167
20148
|
const threadId = body.threadId ?? bot.threadId;
|
|
20149
|
+
const notYours = cloudGuestSendRefusal(auth, threadId);
|
|
20150
|
+
if (notYours)
|
|
20151
|
+
return json(res, 403, { error: notYours });
|
|
19168
20152
|
// Who this message is from, for the ledger. It is captured here and
|
|
19169
20153
|
// travels with the message: into the turn it starts, or into the queue
|
|
19170
20154
|
// until it drains. A message steered into someone else's running turn
|
|
@@ -19425,6 +20409,9 @@ const handleRequest = async (req, res) => {
|
|
|
19425
20409
|
const body = await readBody(req);
|
|
19426
20410
|
requirePinnedClientThread(m[1], body?.threadId);
|
|
19427
20411
|
const bot = requestedTaskBot(m[1], body?.threadId);
|
|
20412
|
+
const notYours = cloudThreadRefusal(auth, bot.threadId);
|
|
20413
|
+
if (notYours)
|
|
20414
|
+
return json(res, 403, { error: notYours });
|
|
19428
20415
|
await startTurn(bot.id, "Summarize conversation context", {
|
|
19429
20416
|
threadId: bot.threadId, compactOnly: true, cardContinuation: true, trigger: usageTriggerFor(auth),
|
|
19430
20417
|
});
|
|
@@ -19440,6 +20427,9 @@ const handleRequest = async (req, res) => {
|
|
|
19440
20427
|
const body = await readBody(req);
|
|
19441
20428
|
requirePinnedClientThread(m[1], body?.threadId);
|
|
19442
20429
|
const bot = requestedTaskBot(m[1], body.threadId);
|
|
20430
|
+
const notYours = cloudThreadRefusal(auth, bot.threadId);
|
|
20431
|
+
if (notYours)
|
|
20432
|
+
return json(res, 403, { error: notYours });
|
|
19443
20433
|
const text = String(body.text ?? "").trim();
|
|
19444
20434
|
if (!text)
|
|
19445
20435
|
return json(res, 400, { error: "text required" });
|
|
@@ -19492,6 +20482,9 @@ const handleRequest = async (req, res) => {
|
|
|
19492
20482
|
const body = await readBody(req);
|
|
19493
20483
|
requirePinnedClientThread(m[1], body?.threadId);
|
|
19494
20484
|
const bot = requestedTaskBot(m[1], body.threadId);
|
|
20485
|
+
const notYours = cloudThreadRefusal(auth, bot.threadId);
|
|
20486
|
+
if (notYours)
|
|
20487
|
+
return json(res, 403, { error: notYours });
|
|
19495
20488
|
if (threadBusy(bot.id, bot.threadId))
|
|
19496
20489
|
return json(res, 409, { error: "the thread is working — stop it before switching versions" });
|
|
19497
20490
|
if (phoneSecretSubmissions.hasThread(bot.threadId)) {
|
|
@@ -19532,7 +20525,7 @@ const handleRequest = async (req, res) => {
|
|
|
19532
20525
|
threadId: bot.threadId,
|
|
19533
20526
|
requestId: String(body.requestId),
|
|
19534
20527
|
behavior,
|
|
19535
|
-
}))
|
|
20528
|
+
}, auth.kind === "session" && cloudOwnerSession(auth)))
|
|
19536
20529
|
return;
|
|
19537
20530
|
if (resolveAndSendProfile(res, {
|
|
19538
20531
|
botId: bot.id,
|
|
@@ -19630,7 +20623,7 @@ const handleRequest = async (req, res) => {
|
|
|
19630
20623
|
threadId,
|
|
19631
20624
|
requestId,
|
|
19632
20625
|
behavior,
|
|
19633
|
-
}))
|
|
20626
|
+
}, auth.kind === "session" && cloudOwnerSession(auth)))
|
|
19634
20627
|
return;
|
|
19635
20628
|
}
|
|
19636
20629
|
const setupCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamSetupRequest);
|
|
@@ -19869,7 +20862,7 @@ const handleRequest = async (req, res) => {
|
|
|
19869
20862
|
return json(res, 500, { error: "couldn't create that task" });
|
|
19870
20863
|
// Who opened it decides who may answer its cards on a shared workspace.
|
|
19871
20864
|
if (auth.kind === "session")
|
|
19872
|
-
threadStarters.set(task.threadId,
|
|
20865
|
+
threadStarters.set(task.threadId, actorKey(auth));
|
|
19873
20866
|
const fresh = botWithThread(store.bot(bot.id));
|
|
19874
20867
|
broadcast({ kind: "bot", bot: fresh });
|
|
19875
20868
|
return json(res, 201, { bot: fresh, task: wireTask(task) });
|
|
@@ -19912,9 +20905,19 @@ const handleRequest = async (req, res) => {
|
|
|
19912
20905
|
const current = store.projectBotForTask(m[1], m[2]);
|
|
19913
20906
|
if (!current)
|
|
19914
20907
|
return json(res, 404, { error: "no such task" });
|
|
19915
|
-
const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface"]);
|
|
20908
|
+
const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface", "refreshPermissions"]);
|
|
19916
20909
|
if (Object.keys(body).some((key) => !allowed.has(key)))
|
|
19917
20910
|
return json(res, 400, { error: "unsupported thread setting" });
|
|
20911
|
+
const notYours = cloudThreadRefusal(auth, m[2]);
|
|
20912
|
+
if (notYours)
|
|
20913
|
+
return json(res, 403, { error: notYours });
|
|
20914
|
+
// On a Cloud home only the owner decides whether a bot may act without
|
|
20915
|
+
// asking them, even in a conversation someone else started, and what
|
|
20916
|
+
// the bot itself defaults to.
|
|
20917
|
+
if (CLOUD_HOME && !cloudOwnerSession(auth) &&
|
|
20918
|
+
(body.approvalMode !== undefined || body.autoApprove !== undefined || body.acknowledgeLocalAuto !== undefined || body.updateBotDefault === true || body.refreshPermissions === true)) {
|
|
20919
|
+
return json(res, 403, { error: "On this Cloud only its owner can change how a bot asks for approval, or its default model." });
|
|
20920
|
+
}
|
|
19918
20921
|
for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
|
|
19919
20922
|
if (body[key] !== undefined && typeof body[key] !== "boolean")
|
|
19920
20923
|
return json(res, 400, { error: `${key} must be a boolean` });
|
|
@@ -19927,6 +20930,50 @@ const handleRequest = async (req, res) => {
|
|
|
19927
20930
|
(body.approvalMode !== undefined && body.approvalMode !== "ask") || body.autoApprove === true)) {
|
|
19928
20931
|
return json(res, 400, { error: "resetApprovalToAsk requires a model selection and cannot be combined with another approval mode" });
|
|
19929
20932
|
}
|
|
20933
|
+
if (body.refreshPermissions !== undefined && body.refreshPermissions !== true) {
|
|
20934
|
+
return json(res, 400, { error: "refreshPermissions must be true" });
|
|
20935
|
+
}
|
|
20936
|
+
if (body.refreshPermissions === true) {
|
|
20937
|
+
const extra = Object.keys(body).filter((key) => key !== "refreshPermissions" && key !== "acknowledgeLocalAuto");
|
|
20938
|
+
if (extra.length)
|
|
20939
|
+
return json(res, 400, { error: "refreshPermissions cannot be combined with other thread settings" });
|
|
20940
|
+
const profile = store.bot(m[1]);
|
|
20941
|
+
if (!profile)
|
|
20942
|
+
return json(res, 404, { error: "no such bot" });
|
|
20943
|
+
if (profile.approvalGrant)
|
|
20944
|
+
return json(res, 409, { error: "the bot's approval mode is still being confirmed" });
|
|
20945
|
+
if (threadBusy(profile.id, m[2]))
|
|
20946
|
+
return json(res, 409, { error: "stop this thread before changing its approval mode" });
|
|
20947
|
+
const nextMode = approvalModeFor({ ...profile, approvalGrant: undefined });
|
|
20948
|
+
// Full and Custom never travel over the bot-reachable HTTP surface.
|
|
20949
|
+
// The desktop's private channel copies those levels, and it is also
|
|
20950
|
+
// the only way to leave Custom.
|
|
20951
|
+
if (nextMode === "full" || nextMode === "custom") {
|
|
20952
|
+
return json(res, 403, { error: "Refresh Full or Custom access from the packaged desktop app" });
|
|
20953
|
+
}
|
|
20954
|
+
if (approvalModeFor(current) === "custom") {
|
|
20955
|
+
return json(res, 403, { error: "Leaving Custom approval requires the packaged desktop app" });
|
|
20956
|
+
}
|
|
20957
|
+
if (!supportsApprovalMode(current.modelSelection, nextMode)) {
|
|
20958
|
+
return json(res, 400, { error: "This provider does not support the selected approval level" });
|
|
20959
|
+
}
|
|
20960
|
+
if (nextMode === "auto" && current.computer === "local" && approvalModeFor(current) !== "auto" && body.acknowledgeLocalAuto !== true) {
|
|
20961
|
+
return json(res, 400, { error: "Auto mode on this computer requires confirming the warning first (acknowledgeLocalAuto)" });
|
|
20962
|
+
}
|
|
20963
|
+
const approvalRank = { ask: 0, edits: 1, auto: 2, full: 3, custom: 4 };
|
|
20964
|
+
const widensMode = approvalRank[nextMode] > approvalRank[approvalModeFor(current)];
|
|
20965
|
+
const nextAllow = profile.alwaysAllow ?? [];
|
|
20966
|
+
const widensAllow = nextAllow.some((key) => !(current.alwaysAllow ?? []).includes(key));
|
|
20967
|
+
if ((widensMode || widensAllow) && auth.kind === "loopback" && !DESKTOP_MANAGED && !req.headers.origin && store.bots.some((bot) => bot.busy)) {
|
|
20968
|
+
return json(res, 409, { error: "Change approval mode from the app or a paired device while bots are working." });
|
|
20969
|
+
}
|
|
20970
|
+
const task = store.refreshTaskPermissions(profile.id, m[2]);
|
|
20971
|
+
if (!task)
|
|
20972
|
+
return json(res, 404, { error: "no such task" });
|
|
20973
|
+
const fresh = botWithThread(store.bot(profile.id));
|
|
20974
|
+
broadcast({ kind: "bot", bot: fresh });
|
|
20975
|
+
return json(res, 200, { task: wireTask(task), bot: fresh });
|
|
20976
|
+
}
|
|
19930
20977
|
const patch = {};
|
|
19931
20978
|
if (body.projectId !== undefined) {
|
|
19932
20979
|
if (body.projectId === null)
|
|
@@ -20040,6 +21087,9 @@ const handleRequest = async (req, res) => {
|
|
|
20040
21087
|
if (!bot || !store.taskByThread(bot.id, m[2])) {
|
|
20041
21088
|
return json(res, 404, { error: "no such task" });
|
|
20042
21089
|
}
|
|
21090
|
+
const notYours = cloudThreadRefusal(auth, m[2]);
|
|
21091
|
+
if (notYours)
|
|
21092
|
+
return json(res, 403, { error: notYours });
|
|
20043
21093
|
if (phoneSecretSubmissions.hasThread(m[2])) {
|
|
20044
21094
|
return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
|
|
20045
21095
|
}
|
|
@@ -20060,6 +21110,55 @@ const handleRequest = async (req, res) => {
|
|
|
20060
21110
|
broadcast({ kind: "bot", bot: fresh });
|
|
20061
21111
|
return json(res, 200, { bot: fresh });
|
|
20062
21112
|
}
|
|
21113
|
+
// Regenerate title (#1858): name the thread again from its conversation
|
|
21114
|
+
// as it is now. The person asked, so the result replaces any title, but
|
|
21115
|
+
// only the one this started from: a rename that lands while the one-shot
|
|
21116
|
+
// runs wins, and the late result is dropped.
|
|
21117
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)\/title$/);
|
|
21118
|
+
if (m && method === "POST") {
|
|
21119
|
+
const current = store.projectBotForTask(m[1], m[2]);
|
|
21120
|
+
if (!current)
|
|
21121
|
+
return json(res, 404, { error: "no such task" });
|
|
21122
|
+
const notYours = cloudThreadRefusal(auth, m[2]);
|
|
21123
|
+
if (notYours)
|
|
21124
|
+
return json(res, 403, { error: notYours });
|
|
21125
|
+
if (!llmThreadTitlesEnabled(cfg))
|
|
21126
|
+
return json(res, 409, { error: "generated thread titles are turned off on this server" });
|
|
21127
|
+
if (hostedModels && !hostedModels.allows(current.modelSelection))
|
|
21128
|
+
return json(res, 409, { error: hostedModels.error() });
|
|
21129
|
+
const instance = registry.get(current.modelSelection.instanceId);
|
|
21130
|
+
if (!instance?.generateText)
|
|
21131
|
+
return json(res, 409, { error: "this bot's model can't generate a title — rename the thread instead" });
|
|
21132
|
+
// An engine the organisation disallows never receives the text, even for a title.
|
|
21133
|
+
const refusal = policyModelRefusal(instance);
|
|
21134
|
+
if (refusal)
|
|
21135
|
+
return json(res, 409, { error: refusal });
|
|
21136
|
+
if (titleRegenerations.has(m[2]))
|
|
21137
|
+
return json(res, 409, { error: "a new title is already being generated for this thread" });
|
|
21138
|
+
const excerpt = titleConversationExcerpt(store.messagesTail(m[2], 200).messages);
|
|
21139
|
+
if (!excerpt)
|
|
21140
|
+
return json(res, 409, { error: "this thread has no messages to take a title from yet" });
|
|
21141
|
+
try {
|
|
21142
|
+
assertWithinBudget(cfg, DATA_DIR);
|
|
21143
|
+
}
|
|
21144
|
+
catch (error) {
|
|
21145
|
+
return json(res, 409, { error: error instanceof Error ? error.message : String(error) });
|
|
21146
|
+
}
|
|
21147
|
+
const startedFrom = store.taskByThread(m[1], m[2]).title;
|
|
21148
|
+
titleRegenerations.add(m[2]);
|
|
21149
|
+
try {
|
|
21150
|
+
const title = await generateThreadTitle(instance, excerpt, "conversation");
|
|
21151
|
+
if (!title)
|
|
21152
|
+
return json(res, 502, { error: "couldn't generate a title — try again, or rename the thread" });
|
|
21153
|
+
const task = store.retitleTask(m[1], m[2], startedFrom, title) ?? store.taskByThread(m[1], m[2]);
|
|
21154
|
+
if (!task)
|
|
21155
|
+
return json(res, 404, { error: "no such task" });
|
|
21156
|
+
return json(res, 200, { task: wireTask(task) });
|
|
21157
|
+
}
|
|
21158
|
+
finally {
|
|
21159
|
+
titleRegenerations.delete(m[2]);
|
|
21160
|
+
}
|
|
21161
|
+
}
|
|
20063
21162
|
// Named team Boats use real independent ownership, never a hidden bot or
|
|
20064
21163
|
// an arbitrary provider id. These new routes remain admin-only by default.
|
|
20065
21164
|
if (path === "/api/team-computers" && method === "GET") {
|
|
@@ -20253,7 +21352,7 @@ const handleRequest = async (req, res) => {
|
|
|
20253
21352
|
// what the user's machine can host: which runtime is installed, whether
|
|
20254
21353
|
// its daemon is up, and whether the desktop image and container exist
|
|
20255
21354
|
if (method === "GET" && path === "/api/local-computer") {
|
|
20256
|
-
return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET));
|
|
21355
|
+
return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET, auth));
|
|
20257
21356
|
}
|
|
20258
21357
|
if (method === "GET" && path === "/api/local-computer/instances") {
|
|
20259
21358
|
res.setHeader("cache-control", "private, no-store");
|
|
@@ -20294,14 +21393,16 @@ const handleRequest = async (req, res) => {
|
|
|
20294
21393
|
localVmLifecycleBusy.add(SHARED_LOCAL_VM_TARGET.key);
|
|
20295
21394
|
try {
|
|
20296
21395
|
const status = await containerComputerAction(action, undefined, undefined, SHARED_LOCAL_VM_TARGET);
|
|
20297
|
-
if (action === "run" || action === "start")
|
|
21396
|
+
if (action === "run" || action === "start") {
|
|
21397
|
+
localVmSeen.add(SHARED_LOCAL_VM_TARGET.key);
|
|
20298
21398
|
localVmIdleFor(SHARED_LOCAL_VM_TARGET).touch();
|
|
21399
|
+
}
|
|
20299
21400
|
if (action === "stop" || action === "remove")
|
|
20300
21401
|
localVmIdleFor(SHARED_LOCAL_VM_TARGET).cancel();
|
|
20301
21402
|
return json(res, 200, {
|
|
20302
|
-
...status,
|
|
21403
|
+
...localVmViewerStatus(status, auth),
|
|
20303
21404
|
commands: setupCommands(status.runtime, process.platform, SHARED_LOCAL_VM_TARGET),
|
|
20304
|
-
idle_timeout_ms:
|
|
21405
|
+
idle_timeout_ms: localVmIdleMs(),
|
|
20305
21406
|
mode: localVmMode(cfg),
|
|
20306
21407
|
max_instances: localVmMaxInstances(cfg),
|
|
20307
21408
|
});
|
|
@@ -20325,9 +21426,9 @@ const handleRequest = async (req, res) => {
|
|
|
20325
21426
|
const bot = computerPreviewBot(m[1], url);
|
|
20326
21427
|
if (!bot)
|
|
20327
21428
|
return json(res, 404, { error: "no such bot" });
|
|
20328
|
-
return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId)));
|
|
21429
|
+
return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId), auth));
|
|
20329
21430
|
}
|
|
20330
|
-
m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/);
|
|
21431
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|start|stop|remove)$/);
|
|
20331
21432
|
if (m && method === "POST") {
|
|
20332
21433
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
20333
21434
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
@@ -20338,7 +21439,7 @@ const handleRequest = async (req, res) => {
|
|
|
20338
21439
|
if (boatLifecycleBusyBots.has(bot.id)) {
|
|
20339
21440
|
return json(res, 409, { error: "this bot's computer is being changed or deleted — wait for it to finish" });
|
|
20340
21441
|
}
|
|
20341
|
-
const action = z.enum(["run", "stop", "remove"]).parse(m[2]);
|
|
21442
|
+
const action = z.enum(["run", "start", "stop", "remove"]).parse(m[2]);
|
|
20342
21443
|
const target = localVmTargetForBot(bot.id);
|
|
20343
21444
|
if (target.key === SHARED_LOCAL_VM_TARGET.key) {
|
|
20344
21445
|
return json(res, 409, { error: "Shared mode manages this desktop in App Settings → Computers" });
|
|
@@ -20377,14 +21478,16 @@ const handleRequest = async (req, res) => {
|
|
|
20377
21478
|
}
|
|
20378
21479
|
}
|
|
20379
21480
|
const status = await containerComputerAction(action, undefined, undefined, target);
|
|
20380
|
-
if (action === "run")
|
|
21481
|
+
if (action === "run" || action === "start") {
|
|
21482
|
+
localVmSeen.add(target.key);
|
|
20381
21483
|
localVmIdleFor(target).touch();
|
|
21484
|
+
}
|
|
20382
21485
|
if (action === "stop" || action === "remove")
|
|
20383
21486
|
localVmIdleFor(target).cancel();
|
|
20384
21487
|
return json(res, 200, {
|
|
20385
|
-
...status,
|
|
21488
|
+
...localVmViewerStatus(status, auth),
|
|
20386
21489
|
commands: setupCommands(status.runtime, process.platform, target),
|
|
20387
|
-
idle_timeout_ms:
|
|
21490
|
+
idle_timeout_ms: localVmIdleMs(),
|
|
20388
21491
|
mode: localVmMode(cfg),
|
|
20389
21492
|
max_instances: localVmMaxInstances(cfg),
|
|
20390
21493
|
});
|
|
@@ -20410,6 +21513,61 @@ const handleRequest = async (req, res) => {
|
|
|
20410
21513
|
image: await containerComputerScreenshot(undefined, undefined, target),
|
|
20411
21514
|
});
|
|
20412
21515
|
}
|
|
21516
|
+
// The Local VM's live desktop for a phone, granted only to the control
|
|
21517
|
+
// lease that holds this bot's computer right now. Two callers:
|
|
21518
|
+
//
|
|
21519
|
+
// The companion sidecar (a loopback caller) gets the VM's own noVNC
|
|
21520
|
+
// address, VNC password included, relays it to a paired phone the owner
|
|
21521
|
+
// has allowed computer access, keeps re-checking the lease with
|
|
21522
|
+
// `action: "check"`, and cuts the relay when it no longer holds.
|
|
21523
|
+
//
|
|
21524
|
+
// A phone paired with this server directly (`openmausbot serve`, no
|
|
21525
|
+
// sidecar) never sees that address. It gets this server's own
|
|
21526
|
+
// authenticated desktop proxy, bound to its lease: the proxy re-checks
|
|
21527
|
+
// the lease and the session every few seconds and closes the socket when
|
|
21528
|
+
// either lapses. Reaching here at all took the admin scope, which a
|
|
21529
|
+
// chat-only pairing (`openmausbot pair --client`) does not have, so
|
|
21530
|
+
// computer access is the same explicit choice the owner's browser makes.
|
|
21531
|
+
m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/join$/);
|
|
21532
|
+
if (m && method === "POST") {
|
|
21533
|
+
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
21534
|
+
return json(res, 415, { error: "content-type must be application/json" });
|
|
21535
|
+
}
|
|
21536
|
+
const bot = computerPreviewBot(m[1], url);
|
|
21537
|
+
if (!bot)
|
|
21538
|
+
return json(res, 404, { error: "no such bot" });
|
|
21539
|
+
const threadId = url.searchParams.has("threadId") ? bot.threadId : undefined;
|
|
21540
|
+
if (threadId && await computerPreviewSurface(bot, threadId) !== "vm") {
|
|
21541
|
+
return json(res, 409, { error: "This conversation is not using the Local VM" });
|
|
21542
|
+
}
|
|
21543
|
+
// A bot's human-control hold does not reserve a shared desktop or pool
|
|
21544
|
+
// seat. Another bot could drive it while the phone is holding this bot.
|
|
21545
|
+
if (localVmMode(cfg) !== "per-bot") {
|
|
21546
|
+
return json(res, 409, { error: "Phone control requires a per-bot Local VM. Select per-bot mode in Settings → Computers." });
|
|
21547
|
+
}
|
|
21548
|
+
const lease = controlLeaseIdSchema.safeParse(url.searchParams.get("controlLeaseId") ?? undefined);
|
|
21549
|
+
if (!lease.success)
|
|
21550
|
+
return json(res, 400, { error: "controlLeaseId is required" });
|
|
21551
|
+
const controlBot = store.bot(bot.id);
|
|
21552
|
+
if (!controlBot || !computerControl.ownsLease(botComputerControlKey(controlBot), lease.data)) {
|
|
21553
|
+
return json(res, 409, { error: "Take control of this computer first" });
|
|
21554
|
+
}
|
|
21555
|
+
const target = localVmTargetForStatus(bot.id, threadId);
|
|
21556
|
+
const status = await containerComputerStatus(undefined, undefined, target);
|
|
21557
|
+
if (!status.ready || !status.managed || status.container !== "running" || status.network !== "loopback"
|
|
21558
|
+
|| !status.viewer_url) {
|
|
21559
|
+
return json(res, 409, { error: status.problem ?? "The Local VM is not ready" });
|
|
21560
|
+
}
|
|
21561
|
+
localVmIdleFor(target).touch();
|
|
21562
|
+
res.setHeader("cache-control", "private, no-store");
|
|
21563
|
+
if (auth.kind === "loopback")
|
|
21564
|
+
return json(res, 200, { joinUrl: status.viewer_url });
|
|
21565
|
+
const socket = new URLSearchParams({ botId: bot.id, ...(threadId ? { threadId } : {}), controlLeaseId: lease.data });
|
|
21566
|
+
return json(res, 200, {
|
|
21567
|
+
socketPath: `api/desktop-viewer/${viewerTargetId(target)}/websockify?${socket}`,
|
|
21568
|
+
password: new URLSearchParams(new URL(status.viewer_url).hash.slice(1)).get("password"),
|
|
21569
|
+
});
|
|
21570
|
+
}
|
|
20413
21571
|
// identity handshake for the packaged app's port fallback: the forked
|
|
20414
21572
|
// child proves it is OURS by echoing its pid (a stray dev server has
|
|
20415
21573
|
// the same API shape but a different pid)
|
|
@@ -20549,7 +21707,7 @@ const handleRequest = async (req, res) => {
|
|
|
20549
21707
|
return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
|
|
20550
21708
|
}
|
|
20551
21709
|
const kind = provider;
|
|
20552
|
-
const saved =
|
|
21710
|
+
const saved = { anthropic: cfg.anthropic, openai: cfg.openai, openrouter: cfg.openrouter, openaiCompat: cfg.openaiCompat, mistral: cfg.mistral, cerebras: cfg.cerebras, xai: cfg.xai }[kind];
|
|
20553
21711
|
if (body?.key !== undefined && typeof body.key !== "string") {
|
|
20554
21712
|
return json(res, 400, { error: "key must be a string" });
|
|
20555
21713
|
}
|
|
@@ -20666,6 +21824,30 @@ const handleRequest = async (req, res) => {
|
|
|
20666
21824
|
providerConfigBusy = false;
|
|
20667
21825
|
}
|
|
20668
21826
|
}
|
|
21827
|
+
if (method === "POST" && path === "/api/instances/chatgpt-accounts") {
|
|
21828
|
+
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json"))
|
|
21829
|
+
return json(res, 415, { error: "content-type must be application/json" });
|
|
21830
|
+
const parsed = createClaudeAccountSchema.pick({ displayName: true }).safeParse(await readBody(req, 8192));
|
|
21831
|
+
if (!parsed.success)
|
|
21832
|
+
return json(res, 400, { error: "Enter an account name (up to 80 characters)." });
|
|
21833
|
+
if (providerConfigBusy)
|
|
21834
|
+
return json(res, 409, { error: "provider settings are already being updated" });
|
|
21835
|
+
providerConfigBusy = true;
|
|
21836
|
+
try {
|
|
21837
|
+
const instances = persistableInstanceConfigs(cfg);
|
|
21838
|
+
const instanceId = `chatgpt-${randomUUID()}`;
|
|
21839
|
+
// Reuse the selected executable, never another account's environment.
|
|
21840
|
+
const cli = instances.chatgpt?.config?.cli
|
|
21841
|
+
?? instances.codex?.config?.cli;
|
|
21842
|
+
instances[instanceId] = { driver: "codex", displayName: parsed.data.displayName,
|
|
21843
|
+
config: { authMode: "chatgpt-plan", ...(typeof cli === "string" && cli ? { cli } : {}) } };
|
|
21844
|
+
await persistProviderInstance(instanceId, instances);
|
|
21845
|
+
return json(res, 201, { instanceId, instances: await describeInstances() });
|
|
21846
|
+
}
|
|
21847
|
+
finally {
|
|
21848
|
+
providerConfigBusy = false;
|
|
21849
|
+
}
|
|
21850
|
+
}
|
|
20669
21851
|
if (method === "POST" && path === "/api/instances/claude-accounts") {
|
|
20670
21852
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
20671
21853
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
@@ -20716,6 +21898,11 @@ const handleRequest = async (req, res) => {
|
|
|
20716
21898
|
const instance = registry.get(instanceId);
|
|
20717
21899
|
if (!instance)
|
|
20718
21900
|
return json(res, 404, { error: "unknown instance" });
|
|
21901
|
+
// The official local OAuth callback terminates on this machine,
|
|
21902
|
+
// not a phone or the browser visiting a remotely hosted workspace.
|
|
21903
|
+
if (instance.authenticationMethod === "browser-pkce" && (isProxied(req) || !isLoopbackHost(req.socket.remoteAddress))) {
|
|
21904
|
+
return json(res, 403, { error: "Continue with ChatGPT on the computer running OpenMausBot. Hosted Pro sign-in requires OpenAI's hosted-app approval." });
|
|
21905
|
+
}
|
|
20719
21906
|
const started = await providerAuthSessions.start(instance, owner);
|
|
20720
21907
|
// Revocation can arrive while the CLI is obtaining a device code.
|
|
20721
21908
|
if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
@@ -20861,8 +22048,8 @@ const handleRequest = async (req, res) => {
|
|
|
20861
22048
|
return json(res, 400, { error: "Account settings are currently available for Claude only." });
|
|
20862
22049
|
}
|
|
20863
22050
|
if (body.tools !== undefined) {
|
|
20864
|
-
if (!["openai-compat", "grok", "minimax", "mistral"].includes(entry.driver)) {
|
|
20865
|
-
return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API and
|
|
22051
|
+
if (!["openai-compat", "grok", "minimax", "mistral", "cerebras"].includes(entry.driver)) {
|
|
22052
|
+
return json(res, 400, { error: "The tools setting is available for OpenAI-compatible, Grok API, MiniMax API, Mistral API and Cerebras API instances only." });
|
|
20866
22053
|
}
|
|
20867
22054
|
entry.config = { ...entry.config, tools: body.tools };
|
|
20868
22055
|
}
|
|
@@ -20939,13 +22126,78 @@ const handleRequest = async (req, res) => {
|
|
|
20939
22126
|
res.once("close", disconnect);
|
|
20940
22127
|
mcpProbesInFlight += 1;
|
|
20941
22128
|
try {
|
|
20942
|
-
|
|
22129
|
+
const name = mcpTest[1];
|
|
22130
|
+
let target = parsed.server;
|
|
22131
|
+
if (isRemoteMcpServer(target) && mcpOAuth.authState(name, target.url) === "signed-in") {
|
|
22132
|
+
const signedIn = (await withMcpSignIn({ [name]: target }, mcpOAuth))[name];
|
|
22133
|
+
if (signedIn)
|
|
22134
|
+
target = { ...signedIn, enabled: target.enabled };
|
|
22135
|
+
}
|
|
22136
|
+
const result = await probeMcpServer(target, undefined, controller.signal);
|
|
22137
|
+
if (!result.ok && result.auth === "required" && isRemoteMcpServer(target))
|
|
22138
|
+
mcpOAuth.markNeedsSignIn(name, target.url);
|
|
22139
|
+
return json(res, 200, result);
|
|
20943
22140
|
}
|
|
20944
22141
|
finally {
|
|
20945
22142
|
res.off("close", disconnect);
|
|
20946
22143
|
mcpProbesInFlight -= 1;
|
|
20947
22144
|
}
|
|
20948
22145
|
}
|
|
22146
|
+
// The loopback listener and authenticated paste-back complete the same flow.
|
|
22147
|
+
const mcpSignIn = /^\/api\/mcp\/servers\/([a-z][a-z0-9_-]{0,31})\/(sign-in|sign-out)(?:\/([0-9a-f-]{36}))?$/.exec(path);
|
|
22148
|
+
if (mcpSignIn) {
|
|
22149
|
+
const [, name, action, flowId] = mcpSignIn;
|
|
22150
|
+
const raw = cfg.mcpServers?.[name];
|
|
22151
|
+
const parsed = raw === undefined ? null : parseStoredMcpServer(name, raw);
|
|
22152
|
+
if (!parsed?.ok)
|
|
22153
|
+
return json(res, 404, { error: "MCP server not found." });
|
|
22154
|
+
if (!isRemoteMcpServer(parsed.server))
|
|
22155
|
+
return json(res, 400, { error: "Only servers reached by URL can be signed in to." });
|
|
22156
|
+
const url = parsed.server.url;
|
|
22157
|
+
if (action === "sign-out" && method === "POST" && !flowId) {
|
|
22158
|
+
await mcpOAuth.signOut(name, url);
|
|
22159
|
+
return json(res, 200, mcpServerResponse());
|
|
22160
|
+
}
|
|
22161
|
+
const owner = auth.kind === "session" ? auth.session.id : "loopback";
|
|
22162
|
+
res.setHeader("Cache-Control", "no-store");
|
|
22163
|
+
try {
|
|
22164
|
+
if (action === "sign-in" && method === "GET" && flowId) {
|
|
22165
|
+
const status = mcpOAuth.status(name, flowId, owner);
|
|
22166
|
+
return status ? json(res, 200, { auth: status }) : json(res, 404, { error: "This sign-in is no longer available in this browser. Start again." });
|
|
22167
|
+
}
|
|
22168
|
+
if (action === "sign-in" && method === "DELETE") {
|
|
22169
|
+
mcpOAuth.cancelFlow(name, owner, flowId);
|
|
22170
|
+
return json(res, 200, { ok: true });
|
|
22171
|
+
}
|
|
22172
|
+
if (action === "sign-in" && method === "POST" && flowId) {
|
|
22173
|
+
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
22174
|
+
return json(res, 415, { error: "content-type must be application/json" });
|
|
22175
|
+
}
|
|
22176
|
+
const body = await readBody(req, 20_480);
|
|
22177
|
+
if (typeof body?.callbackUrl !== "string")
|
|
22178
|
+
return json(res, 400, { error: "A complete callbackUrl is required." });
|
|
22179
|
+
return json(res, 200, { auth: await mcpOAuth.completeCallback(name, flowId, body.callbackUrl, owner) });
|
|
22180
|
+
}
|
|
22181
|
+
if (action === "sign-in" && method === "POST" && !flowId) {
|
|
22182
|
+
const started = await mcpOAuth.start(name, url, undefined, owner);
|
|
22183
|
+
if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
|
|
22184
|
+
mcpOAuth.revokeOwner(owner);
|
|
22185
|
+
return json(res, 401, { error: "Your session ended. Start a new sign-in." });
|
|
22186
|
+
}
|
|
22187
|
+
return json(res, 200, { auth: started });
|
|
22188
|
+
}
|
|
22189
|
+
}
|
|
22190
|
+
catch (error) {
|
|
22191
|
+
if (error instanceof McpSignInError)
|
|
22192
|
+
return json(res, error.status, { error: error.message });
|
|
22193
|
+
if (error instanceof McpOAuthError)
|
|
22194
|
+
return json(res, 400, { error: error.message, code: error.code });
|
|
22195
|
+
if (method === "POST" && !flowId)
|
|
22196
|
+
return json(res, 502, { error: error instanceof Error ? error.message : "Sign-in could not start." });
|
|
22197
|
+
throw error;
|
|
22198
|
+
}
|
|
22199
|
+
return json(res, 405, { error: "method not allowed" });
|
|
22200
|
+
}
|
|
20949
22201
|
if (method === "POST" && path === "/api/mcp/servers") {
|
|
20950
22202
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
20951
22203
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
@@ -21031,6 +22283,7 @@ const handleRequest = async (req, res) => {
|
|
|
21031
22283
|
const next = { ...current };
|
|
21032
22284
|
delete next[name];
|
|
21033
22285
|
persistMcpServers(next);
|
|
22286
|
+
mcpOAuth.forget(name);
|
|
21034
22287
|
return json(res, 200, mcpServerResponse());
|
|
21035
22288
|
}
|
|
21036
22289
|
const existing = parseStoredMcpServer(name, current[name]);
|
|
@@ -21052,6 +22305,17 @@ const handleRequest = async (req, res) => {
|
|
|
21052
22305
|
if (refusal)
|
|
21053
22306
|
return json(res, 403, { error: refusal, code: "managed_policy" });
|
|
21054
22307
|
persistMcpServers({ ...current, [name]: parsed.server });
|
|
22308
|
+
// a sign-in belongs to one address; a token never follows the entry elsewhere
|
|
22309
|
+
const before = isRemoteMcpServer(existing.server) ? existing.server.url : null;
|
|
22310
|
+
const after = isRemoteMcpServer(parsed.server) ? parsed.server.url : null;
|
|
22311
|
+
// ...and to one sign-in app with its scopes; a new secret for the same app keeps it
|
|
22312
|
+
const app = (server) => isRemoteMcpServer(server) && server.oauth
|
|
22313
|
+
? JSON.stringify([server.oauth.clientId, server.oauth.scopes ?? []]) : null;
|
|
22314
|
+
// Any other edit (a new header, say) drops a sign-in request that has
|
|
22315
|
+
// no tokens behind it; the next Test asks again if it still applies.
|
|
22316
|
+
if (before !== after || app(existing.server) !== app(parsed.server)
|
|
22317
|
+
|| (after && mcpOAuth.authState(name, after) === "needs-sign-in"))
|
|
22318
|
+
mcpOAuth.forget(name);
|
|
21055
22319
|
return json(res, 200, mcpServerResponse());
|
|
21056
22320
|
}
|
|
21057
22321
|
finally {
|
|
@@ -21064,9 +22328,13 @@ const handleRequest = async (req, res) => {
|
|
|
21064
22328
|
}
|
|
21065
22329
|
if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
|
|
21066
22330
|
const body = await readBody(req);
|
|
21067
|
-
if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "mistral", "opencodeGo"].some(key => Object.hasOwn(body, key)))
|
|
22331
|
+
if (hostedModels && ["instances", "anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "cerebras", "opencodeGo"].some(key => Object.hasOwn(body, key)))
|
|
21068
22332
|
return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
|
|
21069
22333
|
const patch = parseConfigPatch(body);
|
|
22334
|
+
// A Cloud home is personal: nobody is invited to sign in to it.
|
|
22335
|
+
if (CLOUD_HOME && (patch.signIn?.admins?.length || patch.signIn?.members?.length)) {
|
|
22336
|
+
return json(res, 403, { error: CLOUD_PERSONAL_REFUSAL, code: "cloud_personal" });
|
|
22337
|
+
}
|
|
21070
22338
|
if (patch.newBotDefaults) {
|
|
21071
22339
|
if (patch.newBotDefaults.profile.modelSelection) {
|
|
21072
22340
|
const checked = checkedModelSelection(patch.newBotDefaults.profile.modelSelection);
|
|
@@ -21506,9 +22774,17 @@ const handleRequest = async (req, res) => {
|
|
|
21506
22774
|
}
|
|
21507
22775
|
throw error;
|
|
21508
22776
|
}
|
|
22777
|
+
// Desktops already counting down move to the new window at once, in
|
|
22778
|
+
// every isolation mode; busy work still defers its expiry.
|
|
22779
|
+
if (patch.localVm?.idleTimeoutMinutes !== undefined) {
|
|
22780
|
+
for (const idle of localVmIdles.values())
|
|
22781
|
+
idle.setIdleMs(localVmIdleMs());
|
|
22782
|
+
}
|
|
21509
22783
|
let browserReferenceCleanupError = null;
|
|
21510
|
-
if (patch.signIn !== undefined)
|
|
22784
|
+
if (patch.signIn !== undefined) {
|
|
21511
22785
|
sessions.revalidateEmailSessions();
|
|
22786
|
+
resyncOpenCodeProviderKeys();
|
|
22787
|
+
}
|
|
21512
22788
|
if (!lendingEnabled()) {
|
|
21513
22789
|
sharedComputers.close();
|
|
21514
22790
|
sharedComputerControl.close();
|
|
@@ -21930,13 +23206,19 @@ const handleRequest = async (req, res) => {
|
|
|
21930
23206
|
const result = computerControl.releaseLease(controlKey, controlLeaseId);
|
|
21931
23207
|
return json(res, 200, { ...result.snapshot, released: result.released });
|
|
21932
23208
|
}
|
|
23209
|
+
// Read-only: does this lease still hold the wheel? The companion
|
|
23210
|
+
// sidecar asks this while it relays a phone's live Local VM desktop,
|
|
23211
|
+
// and cuts the relay the moment the answer is no.
|
|
23212
|
+
if (action === "check" && controlLeaseId) {
|
|
23213
|
+
return json(res, 200, { ...computerControl.snapshot(controlKey), owned: computerControl.ownsLease(controlKey, controlLeaseId) });
|
|
23214
|
+
}
|
|
21933
23215
|
if (action === "take")
|
|
21934
23216
|
return json(res, 200, computerControl.take(controlKey));
|
|
21935
23217
|
if (action === "release")
|
|
21936
23218
|
return json(res, 200, computerControl.release(controlKey));
|
|
21937
23219
|
if (action === "dismiss-help")
|
|
21938
23220
|
return json(res, 200, computerControl.dismissHelp(controlKey));
|
|
21939
|
-
return json(res, 400, { error: "action must be take, release, or dismiss-help" });
|
|
23221
|
+
return json(res, 400, { error: "action must be take, release, check, or dismiss-help" });
|
|
21940
23222
|
}
|
|
21941
23223
|
return json(res, 405, { error: "method not allowed" });
|
|
21942
23224
|
}
|
|
@@ -21948,7 +23230,13 @@ const handleRequest = async (req, res) => {
|
|
|
21948
23230
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
21949
23231
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
21950
23232
|
}
|
|
21951
|
-
|
|
23233
|
+
// A directly paired phone's Local VM desktop runs through this server's
|
|
23234
|
+
// own proxy under its lease on this bot; hand-back closes it here, ahead
|
|
23235
|
+
// of the lease re-check. The session's other viewers are left alone.
|
|
23236
|
+
const closedViewers = auth.kind === "session" ? desktopViewer.closeForOwner(auth.session.id, bot.id) : 0;
|
|
23237
|
+
if (bot.cloudBackend === "vps")
|
|
23238
|
+
return json(res, 200, vps.closeVpsDesktopTunnel(bot.id));
|
|
23239
|
+
return json(res, 200, { closed: closedViewers > 0 });
|
|
21952
23240
|
}
|
|
21953
23241
|
m = path.match(/^\/api\/bots\/([\w-]+)\/computer\/(provision|join|sleep|exec|screenshot|remove)$/);
|
|
21954
23242
|
if (m && method === "POST") {
|
|
@@ -21969,6 +23257,10 @@ const handleRequest = async (req, res) => {
|
|
|
21969
23257
|
if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
|
|
21970
23258
|
return json(res, 415, { error: "content-type must be application/json" });
|
|
21971
23259
|
}
|
|
23260
|
+
if (m[2] === "join" && bot.cloudBackend === "vps") {
|
|
23261
|
+
for (let pending; (pending = vpsJoinRequests.get(botId));)
|
|
23262
|
+
await pending;
|
|
23263
|
+
}
|
|
21972
23264
|
const remoteProvider = bot.cloudBackend === "vps" ? "vps" : "box";
|
|
21973
23265
|
if (computerProviderConfigTransitions.has(remoteProvider)) {
|
|
21974
23266
|
return json(res, 409, { error: providerTransitionMessage(remoteProvider) });
|
|
@@ -22016,6 +23308,10 @@ const handleRequest = async (req, res) => {
|
|
|
22016
23308
|
// Opening the existing SSH viewer can coexist with a capture. Start,
|
|
22017
23309
|
// stop, remove and Settings deletion still exclude pending previews.
|
|
22018
23310
|
const releaseComputerLifecycle = claimBotComputerLifecycle(botId, m[2] === "join");
|
|
23311
|
+
// Settles only after the lane is released, so a waiter can claim it.
|
|
23312
|
+
let joinSettled;
|
|
23313
|
+
if (m[2] === "join")
|
|
23314
|
+
vpsJoinRequests.set(botId, new Promise(resolve => { joinSettled = resolve; }));
|
|
22019
23315
|
try {
|
|
22020
23316
|
if (m[2] === "exec") {
|
|
22021
23317
|
return json(res, 409, { error: "the VPS console is available to the bot through its scoped computer tools" });
|
|
@@ -22027,13 +23323,20 @@ const handleRequest = async (req, res) => {
|
|
|
22027
23323
|
return json(res, 409, { error: "the VPS computer is being used by this bot — interrupt the turn first" });
|
|
22028
23324
|
}
|
|
22029
23325
|
if (m[2] === "join") {
|
|
22030
|
-
|
|
23326
|
+
const remote = auth.kind !== "loopback";
|
|
23327
|
+
const result = await vps.vpsComputerJoin(cfg, botId, undefined, remote);
|
|
23328
|
+
return json(res, 200, { ...result, joinUrl: remote
|
|
23329
|
+
? desktopViewerUrl(`vps/${botId}`, bot.threadId) : result.joinUrl });
|
|
22031
23330
|
}
|
|
22032
23331
|
const action = m[2] === "provision" ? "provision" : m[2] === "remove" ? "remove" : "stop";
|
|
22033
23332
|
return json(res, 200, await vps.vpsComputerAction(action, cfg, botId));
|
|
22034
23333
|
}
|
|
22035
23334
|
finally {
|
|
22036
23335
|
releaseComputerLifecycle();
|
|
23336
|
+
if (joinSettled) {
|
|
23337
|
+
vpsJoinRequests.delete(botId);
|
|
23338
|
+
joinSettled();
|
|
23339
|
+
}
|
|
22037
23340
|
}
|
|
22038
23341
|
}
|
|
22039
23342
|
const activeBoatTurn = botHasActiveTurn(botId);
|
|
@@ -22098,6 +23401,7 @@ const handleRequest = async (req, res) => {
|
|
|
22098
23401
|
}
|
|
22099
23402
|
};
|
|
22100
23403
|
const server = createServer(handleRequest);
|
|
23404
|
+
desktopViewer.attach(server, handleRequest);
|
|
22101
23405
|
calendarCalls.start();
|
|
22102
23406
|
// Resolve the edition before accepting requests so /api/edition is never a guess.
|
|
22103
23407
|
console.log(describeEdition(await loadEnterpriseLayer()));
|
|
@@ -22278,6 +23582,7 @@ if (TUNNEL_SOCKET) {
|
|
|
22278
23582
|
if (process.platform !== "win32")
|
|
22279
23583
|
rmSync(TUNNEL_SOCKET, { force: true });
|
|
22280
23584
|
tunnelListener = createServer(handleRequest);
|
|
23585
|
+
desktopViewer.attach(tunnelListener, handleRequest);
|
|
22281
23586
|
tunnelListener.listen(TUNNEL_SOCKET, () => {
|
|
22282
23587
|
console.log(`openmausbot tunnel listener on ${TUNNEL_SOCKET}`);
|
|
22283
23588
|
});
|
|
@@ -22296,6 +23601,7 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
22296
23601
|
sharedComputers.close();
|
|
22297
23602
|
sharedComputerControl.close();
|
|
22298
23603
|
browserLive.closeAll();
|
|
23604
|
+
desktopViewer.closeAll();
|
|
22299
23605
|
for (const idle of localVmIdles.values())
|
|
22300
23606
|
idle.cancel();
|
|
22301
23607
|
vps.closeAllVpsDesktopTunnels();
|
|
@@ -22332,6 +23638,7 @@ const gracefulShutdown = createGracefulShutdown({
|
|
|
22332
23638
|
code = 1;
|
|
22333
23639
|
}
|
|
22334
23640
|
closeMessageDb();
|
|
23641
|
+
mcpOAuth.dispose();
|
|
22335
23642
|
releaseDataDirLeaseAtExit();
|
|
22336
23643
|
// A Cloud home's launcher starts the server again on this code only.
|
|
22337
23644
|
process.exit(cloudHomeRestartRequested && code === 0 ? CLOUD_HOME_RESTART_EXIT_CODE : code);
|