openmausbot 0.1.90 → 0.1.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/dist/assets/DesktopViewer-Do6lBa4C.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-DUyXd0kL.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
  3. package/dist/assets/{arc-q73qM-At.js → arc-DIYc7Ymx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-D33O3Wur.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DLsJeoXC.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DnaljZnu.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
  7. package/dist/assets/channel-Dtk5Mtic.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-Da0h2i79.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-CfzHi54f.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-LjE4d9Jf.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DY-UGmvc.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-BqeGhyhP.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BYa9EBAg.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-vTrDtWyb.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-ic5tXadM.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-DvRCY-c6.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-DVbmgz_L.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-vdUs2n57.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-BvLGlV3U.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-C4GcJQDt.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-BMSaHpt_.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-D5b9UBhC.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CIKLlB7Y.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BzXb-7k7.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-HsTvJ7uT.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-Db9ySNbQ.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-D6BKbpqY.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-Bq067L0t.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-DhaD7fNJ.js → elk-276RUBZZ-v6bj852W.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-fwIhfz36.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-DygIyu0V.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-DPYfSO96.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-DhkHeCle.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
  34. package/dist/assets/index-Cdmjrl28.css +1 -0
  35. package/dist/assets/{index-G1PHKGyh.js → index-DGBaEXrM.js} +1 -1
  36. package/dist/assets/index-DR7YPMkW.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-DstBLxME.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-CrIsGgch.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-CYypGDbB.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DGjPt8E_.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
  41. package/dist/assets/{linear-Dwc3Hrru.js → linear-CmOWzX0P.js} +1 -1
  42. package/dist/assets/{mermaid.core-Dc4OePBL.js → mermaid.core-CF1zwoQ-.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DV-vepj-.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-lx5Kj-n5.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-Bq7Vmm15.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-DeqeqOBj.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-CXwRQzSC.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-8tAeoB88.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-BG5zjGWU.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DKF3iLcw.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-B-y1qUgT.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-MkzPgxAc.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DnbaOEwH.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-Bb4xG_Bt.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-JgkR9pFf.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-CIPIrAYe.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-BjCLuUvJ.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DKgiaYxT.js → xychartDiagram-PMCCYNJV-Dc_fL4mk.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist/novnc-NOTICE.txt +118 -0
  62. package/dist-server/cloud-home-start.js +128 -15
  63. package/dist-server/container-mcp.js +277 -45
  64. package/dist-server/drivers/agents-proxy.js +16 -4
  65. package/dist-server/enterprise/server/index.js +5 -1
  66. package/dist-server/index.js +46703 -39955
  67. package/dist-server/local-computer-proxy.js +128 -7
  68. package/dist-server/mcp-gate.js +11 -5
  69. package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
  70. package/dist-server/openmausbot.js +4099 -1217
  71. package/dist-server/pair-cli.js +4099 -1217
  72. package/dist-server/server/boat.js +67 -22
  73. package/dist-server/server/bot-overview.js +7 -4
  74. package/dist-server/server/browser-engine.js +10 -1
  75. package/dist-server/server/browser-live.js +46 -2
  76. package/dist-server/server/browser-runtime.js +74 -8
  77. package/dist-server/server/browser-tool-shape.js +6 -4
  78. package/dist-server/server/chief-of-staff.js +1 -1
  79. package/dist-server/server/cloud-home-start.js +136 -26
  80. package/dist-server/server/cloud-home.js +64 -5
  81. package/dist-server/server/cloud-lending.js +167 -0
  82. package/dist-server/server/cloud-move-http.js +268 -0
  83. package/dist-server/server/cloud-move.js +418 -0
  84. package/dist-server/server/cloud-owner.js +196 -0
  85. package/dist-server/server/cloud-secrets-boot.js +6 -0
  86. package/dist-server/server/cloud-secrets.js +65 -0
  87. package/dist-server/server/config.js +215 -17
  88. package/dist-server/server/container-computer.js +3 -2
  89. package/dist-server/server/decider/index.js +238 -0
  90. package/dist-server/server/decider/jev.js +183 -0
  91. package/dist-server/server/decider/log.js +62 -0
  92. package/dist-server/server/decider/relay.js +52 -0
  93. package/dist-server/server/decider/room-routing.js +88 -0
  94. package/dist-server/server/decider/types.js +5 -0
  95. package/dist-server/server/desktop-viewer-targets.js +26 -0
  96. package/dist-server/server/drivers/acp/core.js +167 -21
  97. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  98. package/dist-server/server/drivers/agents-catalog.js +22 -6
  99. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  100. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  101. package/dist-server/server/drivers/antigravity.js +21 -0
  102. package/dist-server/server/drivers/boatagent.js +20 -12
  103. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  104. package/dist-server/server/drivers/claude.js +313 -34
  105. package/dist-server/server/drivers/codex.js +228 -18
  106. package/dist-server/server/drivers/grok.js +2 -2
  107. package/dist-server/server/drivers/mistral.js +4 -3
  108. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  109. package/dist-server/server/drivers/openai-chat.js +41 -3
  110. package/dist-server/server/drivers/openai-compat.js +73 -23
  111. package/dist-server/server/engine-install.js +46 -12
  112. package/dist-server/server/env-path.js +15 -3
  113. package/dist-server/server/group-goal-run.js +6 -4
  114. package/dist-server/server/harness/registry.js +2 -2
  115. package/dist-server/server/harness-capabilities.js +2 -0
  116. package/dist-server/server/included-services.js +70 -0
  117. package/dist-server/server/index.js +1753 -263
  118. package/dist-server/server/lending-memory.js +479 -0
  119. package/dist-server/server/local-vm-idle.js +24 -6
  120. package/dist-server/server/managed-desktop.js +3 -0
  121. package/dist-server/server/mcp-bridge.js +5 -3
  122. package/dist-server/server/mcp-http.js +6 -3
  123. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  124. package/dist-server/server/mcp-oauth-store.js +70 -0
  125. package/dist-server/server/mcp-oauth.js +550 -0
  126. package/dist-server/server/mcp-probe.js +14 -1
  127. package/dist-server/server/mcp-registry.js +43 -0
  128. package/dist-server/server/memory-upkeep.js +94 -84
  129. package/dist-server/server/model-catalog/catalog.js +374 -0
  130. package/dist-server/server/model-catalog/presets.js +176 -0
  131. package/dist-server/server/model-catalog/trim.js +219 -0
  132. package/dist-server/server/model-context-window.js +4 -4
  133. package/dist-server/server/package-export.js +17 -10
  134. package/dist-server/server/package-import.js +6 -3
  135. package/dist-server/server/package-parts.js +3 -1
  136. package/dist-server/server/provider-key-check.js +4 -2
  137. package/dist-server/server/recent-work.js +1 -1
  138. package/dist-server/server/request-auth.js +25 -0
  139. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  140. package/dist-server/server/routes/bot-memory.js +31 -6
  141. package/dist-server/server/routes/decider.js +19 -0
  142. package/dist-server/server/routes/desktop-viewer.js +194 -0
  143. package/dist-server/server/routine-requests.js +1 -1
  144. package/dist-server/server/routines.js +1 -1
  145. package/dist-server/server/sessions.js +56 -9
  146. package/dist-server/server/shared-computers.js +116 -30
  147. package/dist-server/server/skills.js +25 -10
  148. package/dist-server/server/store.js +79 -124
  149. package/dist-server/server/surface.js +13 -0
  150. package/dist-server/server/system-prompt.js +11 -0
  151. package/dist-server/server/team-backup.js +6 -2
  152. package/dist-server/server/team-manifest.js +11 -3
  153. package/dist-server/server/thread-events.js +2 -0
  154. package/dist-server/server/thread-starters.js +33 -0
  155. package/dist-server/server/thread-title.js +46 -0
  156. package/dist-server/server/tts/elevenlabs.js +11 -6
  157. package/dist-server/server/tts/fish.js +4 -5
  158. package/dist-server/server/tts/index.js +20 -11
  159. package/dist-server/server/vps-computer.js +79 -12
  160. package/dist-server/server/webhooks.js +18 -1
  161. package/dist-server/server/workspace-backup-policy.js +25 -3
  162. package/dist-server/server/workspace-backup.js +28 -8
  163. package/dist-server/server/workspace.js +65 -15
  164. package/dist-server/shared/cloud-home.js +4 -0
  165. package/dist-server/shared/package-format.js +30 -4
  166. package/dist-server/shared/team-backup.js +20 -3
  167. package/dist-server/shared/workspace-backup-client.js +1 -0
  168. package/dist-server/src/lib/citations.js +177 -0
  169. package/dist-server/src/lib/composer-attachments.js +7 -1
  170. package/dist-server/vps-container-mcp.js +278 -45
  171. package/dist-server/workspace-backup.worker.js +184 -38
  172. package/enterprise/server/index.js +5 -1
  173. package/package.json +1 -1
  174. package/dist/assets/channel-BCuyHjFV.js +0 -1
  175. package/dist/assets/index-CRvo-DS2.css +0 -1
  176. package/dist/assets/index-CWYGlA63.js +0 -588
  177. package/dist/assets/swimlanesDiagram-TC7HE7FX-BZQ07DGE.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";
@@ -15,7 +18,9 @@ import { selectReplay, DEFAULT_REBUILD_BYTES, MAX_SUMMARY_BYTES } from "./contex
15
18
  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
- import { SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
21
+ import { provenRequestPerson, SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
22
+ import { cloudHomeLendingRefusal, createCloudRoutineAuthors, ownerOnlyConversation } from "./cloud-lending.js";
23
+ import { botMemoryFiles, createLendingMemory } from "./lending-memory.js";
19
24
  import { SharedComputerControl } from "./shared-computer-control.js";
20
25
  import { RoomHandoffs } from "./room-handoffs.js";
21
26
  import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsInput, requestSourceForCard } from "./guarded-requests.js";
@@ -60,7 +65,7 @@ import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, liv
60
65
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
61
66
  import { containerComputerAction, containerComputerExists, containerComputerFrame, containerComputerMcp, containerComputerScreenshot, containerComputerStatus, containerExec, containerRuntimeStatus, localVmRecreatableOnDemand, localVmWorkspaceExists, perBotLocalVmTarget, poolLocalVmTarget, SHARED_LOCAL_VM_TARGET, setupCommands, VM_WORKSPACE_GUEST, } from "./container-computer.js";
62
67
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
63
- 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, 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";
64
69
  import { sweepThreadEventLogs } from "./thread-retention.js";
65
70
  import { ComputerControl } from "./computer-control.js";
66
71
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -79,10 +84,12 @@ import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
79
84
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
80
85
  import { recoveryCapabilityError } from "./automatic-recovery.js";
81
86
  import { decodeGeneratedImage } from "./generated-image.js";
82
- 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";
83
89
  import { probeMcpServer } from "./mcp-probe.js";
84
90
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
85
91
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
92
+ import { openCodeProviderKeysAllowed, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
86
93
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
87
94
  import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
88
95
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
@@ -103,7 +110,10 @@ import { ProviderRegistry } from "./harness/registry.js";
103
110
  import { ManagedDesktopProviders } from "./managed-desktop.js";
104
111
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
105
112
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
106
- import { CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, 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";
115
+ import { createCloudMoveRoutes } from "./cloud-move-http.js";
116
+ import { holdIncludedServices } from "./included-services.js";
107
117
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
108
118
  import { cancelPeerApprovalsFor, cancelPeerApprovalsForThread, dismissStalePeerCards, peerApprovalFailure, requestPeerApproval, resolvePeerComms } from "./peer-approval.js";
109
119
  import { peerDeliveryReceipt } from "./peer-delivery.js";
@@ -111,14 +121,17 @@ import { peerProvenanceNote, withPeerProvenance } from "./peer-provenance.js";
111
121
  import { decideRoomPost, emptyRoomPostBudget } from "./room-post-budget.js";
112
122
  import { isProjectEmoji, mentionedBots, parseConnectorTools, roomResponders, sectionKey, Store, titleFromLlm, toWireTask, } from "./store.js";
113
123
  import * as tts from "./tts/index.js";
124
+ import { createDecider, deciderIncludedHere, deciderReady, deciderSavePatch, describeDecider } from "./decider/index.js";
125
+ import { decideRoomResponder } from "./decider/room-routing.js";
114
126
  import { narrateTool, toUtterances } from "./tts/speech-text.js";
115
127
  import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
116
128
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
117
129
  import { extractTurnImages } from "./turn-images.js";
130
+ import { threadTitlePrompt, titleConversationExcerpt } from "./thread-title.js";
118
131
  import { TurnWatchdog } from "./turn-watchdog.js";
119
132
  import { TurnResources, workspaceResource } from "./turn-resources.js";
120
133
  import { IdleReleasePolicy } from "./claim-idle.js";
121
- 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";
122
135
  import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
123
136
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
124
137
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
@@ -129,7 +142,7 @@ import { botSkillTemplateSchema, newBotDefaultsSchema, resolveBotCreationDefault
129
142
  import { expandLearnTurnText, learnSource } from "./skill-learn.js";
130
143
  import { expandSetupTurnText, setupModeActive, setupSystemPrompt } from "./setup-mode.js";
131
144
  import { checkSoulDrift, readSoulDrift, soulFile, writeSoulMirror } from "./bot-folder.js";
132
- import { buildSystemPrompt, userProfileSystemPrompt, computerPrompt, composioSystemPrompt, customMcpPrompt, CREDENTIAL_PROMPT, mentionPrompt, THREADS_PROMPT, LEARN_PROMPT, PROFILE_PROMPT, ROUTINE_PROMPT, ROUTINE_EXECUTION_PROMPT, WEBHOOK_PROMPT, resolveComputerPromptKind, } from "./system-prompt.js";
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";
133
146
  import { readCuaConnection, readCuaUnavailableReason, gatedLocalComputer } from "./local-computer.js";
134
147
  import { discoverExistingPerBotLocalVms, discoverExistingPoolLocalVms, localVmInventoryEntry, shouldArmLocalVmIdle, } from "./local-vm-inventory.js";
135
148
  import { LocalVmIdleTimer } from "./local-vm-idle.js";
@@ -141,12 +154,12 @@ import * as vps from "./vps-computer.js";
141
154
  import { RoutineManager } from "./routines.js";
142
155
  import { CalendarCallManager } from "./calendar-calls.js";
143
156
  import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
144
- import { BrowserRuntime } from "./browser-runtime.js";
157
+ import { BrowserRuntime, browserRuntimeEnv } from "./browser-runtime.js";
145
158
  import { BrowserLive } from "./browser-live.js";
146
- import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
159
+ import { agentBrowserFrame, agentBrowserIntegration, browserEngineEncryptionKey, prepareBrowserSessionState, clearBrowserSessionState, closeBrowserSession, ensureChrome, installAgentBrowserBinary, resolveAgentBrowserBinary, browserEngineStatus, browserSessionId, describeBrowserEngine, } from "./browser-engine.js";
147
160
  import { createScreenFrameSource } from "./screen-frame-source.js";
148
161
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
149
- import { RoutineRequestService } from "./routine-requests.js";
162
+ import { asSchedule, RoutineRequestService } from "./routine-requests.js";
150
163
  import { createOptionsCard } from "./options-card.js";
151
164
  import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
152
165
  import { ProfileRequestService } from "./profile-requests.js";
@@ -208,6 +221,11 @@ import { ROUTES, dispatchRoutes } from "./routes/table.js";
208
221
  import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
209
222
  import { createBotPresetRoutes } from "./routes/bot-presets.js";
210
223
  import { createBotMemoryRoutes } from "./routes/bot-memory.js";
224
+ import { createDeciderRoutes } from "./routes/decider.js";
225
+ import { createDesktopViewer, desktopViewerUrl } from "./routes/desktop-viewer.js";
226
+ import { localDesktopTarget, localVmViewerStatus, viewerTargetId } from "./desktop-viewer-targets.js";
227
+ import { createAntigravityLeftoverRoutes } from "./routes/antigravity-leftovers.js";
228
+ import { findAntigravityLeftovers, removeAntigravityLeftovers } from "./drivers/antigravity-temp.js";
211
229
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
212
230
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
213
231
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -217,6 +235,7 @@ const MIME = {
217
235
  ".html": "text/html",
218
236
  ".js": "text/javascript",
219
237
  ".css": "text/css",
238
+ ".txt": "text/plain; charset=utf-8",
220
239
  ".svg": "image/svg+xml",
221
240
  ".png": "image/png",
222
241
  ".ico": "image/x-icon",
@@ -246,8 +265,12 @@ process.once("exit", releaseDataDirLeaseAtExit);
246
265
  // Restore before constructing any long-lived config, Store, session or provider
247
266
  // objects. Replacing files underneath a live Store would overwrite restored data.
248
267
  let workspaceRestore = { restored: false };
268
+ /** A restore applied at this very start (not the last one on record). */
269
+ let restoredAtStart;
249
270
  if (existsSync(join(DATA_DIR, ".backups"))) {
250
271
  workspaceRestore = applyPendingWorkspaceRestore(DATA_DIR);
272
+ if (workspaceRestore.restored)
273
+ restoredAtStart = workspaceRestore.id;
251
274
  if (!workspaceRestore.restored && !workspaceRestore.rolledBack) {
252
275
  workspaceRestore = readLastWorkspaceRestore(DATA_DIR) ?? workspaceRestore;
253
276
  }
@@ -273,7 +296,50 @@ const sessions = new SessionRegistry({
273
296
  const sharedComputers = new SharedComputers(id => sessions.isLive(id));
274
297
  // OMB Cloud Pro home machine (server/cloud-home.ts, docs/cloud-pro.md). A
275
298
  // partial or invalid boot contract stops the server here, before it serves.
276
- const CLOUD_HOME = cloudHomeConfiguration();
299
+ // Its secrets come over the launcher's pipe, never this process's
300
+ // environment (cloud-home-start.ts); a server started another way (tests,
301
+ // development) still reads them from its environment.
302
+ const CLOUD_SECRETS = BOOT_CLOUD_SECRETS;
303
+ const CLOUD_ENV = { ...process.env, ...CLOUD_SECRETS };
304
+ const CLOUD_HOME = cloudHomeConfiguration(CLOUD_ENV);
305
+ // A Cloud home is personal: only the owner's own devices, each with admin
306
+ // scope, connect (server/cloud-owner.ts; the stored rest is revoked below).
307
+ if (CLOUD_HOME)
308
+ sessions.requireAdmin(CLOUD_PERSONAL_REFUSAL);
309
+ /** Lending a computer to this server (the shared-computer routes, the two
310
+ * agent tools, the advertised capability): the maintainer flag anywhere, and
311
+ * always on an OMB Cloud home, where it is the person's own Mac lent to their
312
+ * own Cloud (docs/cloud-pro.md, "Let my Cloud use this Mac"). */
313
+ const lendingEnabled = () => sharedComputersEnabled(cfg) || CLOUD_HOME !== null;
314
+ /** On a Cloud home every lent computer and every turn belong to one person. */
315
+ const CLOUD_HOME_LENDER = "cloud-home";
316
+ /** Routines whose instructions the Cloud home's owner wrote, and manual runs
317
+ * the owner started: the only routine turns that may use a lent Mac. */
318
+ const cloudRoutineAuthors = CLOUD_HOME ? createCloudRoutineAuthors(join(DATA_DIR, "lending-routines.json")) : null;
319
+ const ownerStartedRoutineRuns = new Set();
320
+ /** Turns running now that are not provably the owner's, by generation. */
321
+ const foreignTurns = new Map();
322
+ /** Whether each bot's memory is still the owner's (server/lending-memory.ts):
323
+ * its memory files, and the instruction files its engine reads in the
324
+ * folders its conversations work in. */
325
+ const lendingMemory = CLOUD_HOME ? createLendingMemory({
326
+ file: join(DATA_DIR, "lending-memory.json"),
327
+ files: (botId) => {
328
+ const bot = store.bot(botId);
329
+ // The workspace as a turn dispatch leaves it: a bot's first turn seeds
330
+ // MEMORY.md, and that seed is not a change anyone made. Only the folders
331
+ // of conversations the owner opened: what a guest's own conversation
332
+ // writes in its own private folder reaches no turn of the owner's.
333
+ // (A bot's own conversations are never a room's: its opener is enough.)
334
+ const ownerOpened = (threadId) => { const starter = threadStarters.get(threadId); return starter === undefined || cloudOwnerPerson(starter); };
335
+ 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 });
336
+ },
337
+ knownBots: () => store.bots.map((bot) => bot.id),
338
+ log: (line) => console.warn(line),
339
+ }) : null;
340
+ // On a Cloud home memory is never read through a link (server/workspace.ts).
341
+ if (CLOUD_HOME)
342
+ readMemoryOnlyFromRegularFiles();
277
343
  const cloudPairing = CLOUD_HOME ? createCloudPairing({ secret: CLOUD_HOME.bootstrapSecret, sessions }) : null;
278
344
  if (CLOUD_HOME) {
279
345
  // The signing secret is held in memory from here on, and a platform
@@ -286,6 +352,14 @@ if (CLOUD_HOME) {
286
352
  for (const warning of CLOUD_HOME.warnings)
287
353
  console.warn(`cloud home: ${warning}`);
288
354
  }
355
+ // Cloud Pro's included Boat, voice and decision relay tokens, when the Admin
356
+ // set them: held in memory from here on, like the signing secret.
357
+ holdIncludedServices(CLOUD_ENV);
358
+ for (const key of CLOUD_HOME_SECRET_KEYS)
359
+ delete process.env[key];
360
+ if (CLOUD_HOME && Object.keys(CLOUD_SECRETS).length === 0) {
361
+ 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");
362
+ }
289
363
  // Who each thread is for, when a signed-in person can be named (server-private).
290
364
  const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
291
365
  const commandAllowlist = new CommandAllowlistStore(join(DATA_DIR, "command-allowlist.json"));
@@ -298,7 +372,7 @@ const sharedWorkspaceFullAccessEnabled = () => SHARED_WORKSPACE_FULL_ACCESS && B
298
372
  // Who a loopback request without a session is (server/request-auth.ts
299
373
  // LoopbackTrust): the owner on a desktop or a one-person server; a service on
300
374
  // a shared workspace, where every bot's shell is a loopback caller too.
301
- const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE });
375
+ const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME) });
302
376
  // `openmausbot serve` on a service-trust server hands the server it starts a
303
377
  // per-launch secret on stdin, then closes it (server/cli.ts). It opens only
304
378
  // the pairing route, for that CLI. Never an environment variable: every
@@ -341,6 +415,9 @@ bindThreadLogCapProvider(() => threadEventLogMaxBytes(cfg));
341
415
  // The decision log keeps month files for at least this many days. Read per
342
416
  // prune, so a Settings change applies at the next one without a restart.
343
417
  bindDecisionRetention(() => decisionRetentionDays(cfg.decisions?.retentionDays));
418
+ // The decision model (server/decider): reads cfg per call, so a Settings
419
+ // save applies to the next decision. It never throws into a turn.
420
+ const decider = createDecider({ config: () => cfg, dataDir: DATA_DIR });
344
421
  const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
345
422
  // "Sign in with your email" on /pair: the allow-list is read per call so a
346
423
  // Settings change or an env bootstrap applies without a restart.
@@ -393,7 +470,7 @@ function messageSender(auth) {
393
470
  if (auth.kind !== "session")
394
471
  return undefined;
395
472
  const name = (auth.session.email ?? auth.session.label ?? "").trim();
396
- return name ? { name, id: personKey(auth.session) } : undefined;
473
+ return name ? { name, id: actorKey(auth) } : undefined;
397
474
  }
398
475
  /** An opaque, stable key for the person behind a session: their account
399
476
  * email when they signed in with one (a new device is still them), else the
@@ -438,6 +515,404 @@ function threadPersonKey(threadId) {
438
515
  : thread.findLast((message) => message.role === "user");
439
516
  return linePersonKey(request) ?? threadStarters.get(threadId);
440
517
  }
518
+ /** The person whose lent computers this bot turn may use, or null.
519
+ * Fail closed: only the person whose own message the harness proved started
520
+ * this exact turn generation (continuations of that same request keep it).
521
+ * A routine, a webhook, a room, a bot's delegated or peer turn, the owner on
522
+ * this machine (who lends nothing to itself) and anything unprovable act for
523
+ * nobody here. Deliberately not the usage ledger's trigger, which defaults to
524
+ * the owner. */
525
+ function sharedComputerPrincipal(capability) {
526
+ // A Cloud home lends the owner's Mac only to turns that provably act for
527
+ // the owner (server/cloud-lending.ts): their own conversation from one of
528
+ // their devices, or a routine they wrote. Never a guest, a webhook, a room
529
+ // or a line someone else slipped in.
530
+ if (CLOUD_HOME)
531
+ return cloudLendingRefusal(capability) === null ? CLOUD_HOME_LENDER : null;
532
+ return provenRequestPerson(directRequestOwners.get(capability.threadId), capability.generation, (messageId) => linePersonKey(store.messagesFor(capability.threadId).find((message) => message.id === messageId)));
533
+ }
534
+ /** What the lending rule needs to know about one bot turn on a Cloud home. */
535
+ function cloudLendingTurn(capability) {
536
+ return {
537
+ request: directRequestOwners.get(capability.threadId),
538
+ generation: capability.generation,
539
+ thread: store.messagesFor(capability.threadId),
540
+ starters: cloudThreadStarters(capability.threadId),
541
+ reportsFromOthers: store.messagesFor(capability.threadId).some(cloudOthersRoutineReport),
542
+ ownerPerson: cloudProvenOwnerPerson,
543
+ cardAnswerer: cloudCardAnswerer,
544
+ routineRun: () => {
545
+ const run = activeRoutineRunForThread(capability.threadId);
546
+ if (!run || run.threadId !== capability.threadId || store.taskByThread(capability.botId, capability.threadId)?.routineRunId !== run.id)
547
+ return null;
548
+ return {
549
+ triggerSource: run.triggerSource ?? (run.manual ? "manual" : "schedule"),
550
+ ownerStarted: ownerStartedRoutineRuns.has(run.id),
551
+ // What the run snapshotted, on the schedule its routine has now.
552
+ ownerAuthored: cloudRoutineRunIsOwners(run, capability.botId, capability.threadId),
553
+ };
554
+ },
555
+ };
556
+ }
557
+ /** What a bot on a Cloud home is told when its conversation cannot use the
558
+ * owner's lent Mac because someone else wrote in it. The bot relays it. */
559
+ const LENDING_SOMEONE_ELSE = "Someone else wrote in this conversation, so it can't use your Mac. Start a new conversation to use it.";
560
+ /** …or because its memory was changed where the owner did not write. */
561
+ 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.";
562
+ /** Why this bot turn may not use the owner's lent Mac on a Cloud home, or
563
+ * null: the conversation's provenance (server/cloud-lending.ts), then the
564
+ * bot's memory, which every one of its turns loads (server/lending-memory.ts). */
565
+ function cloudLendingRefusal(capability) {
566
+ const refusal = cloudHomeLendingRefusal(cloudLendingTurn(capability));
567
+ if (refusal)
568
+ return refusal;
569
+ // Checked on every Mac action: one snapshot per tick is enough to look.
570
+ return lendingMemory?.reconcile(capability.botId, foreignTurnRunning(capability.botId), { cached: true }).changedBySomeoneElse ? "memory-changed" : null;
571
+ }
572
+ /** Who opened a conversation, and the room it is in: on a Cloud home a
573
+ * person who can name, title and steer it (see cloudThreadRefusal). */
574
+ function cloudThreadStarters(threadId) {
575
+ const room = store.groupByThread(threadId);
576
+ return [threadStarters.get(threadId), ...(room && room.threadId !== threadId ? [threadStarters.get(room.threadId)] : [])];
577
+ }
578
+ /** On a Cloud home: a turn that may use the owner's lent Mac, as far as its
579
+ * conversation goes. Such a turn reads only the owner's own conversations
580
+ * (session_search, session_read, list_threads). */
581
+ function cloudLendingView(capability) {
582
+ return CLOUD_HOME !== null && cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null;
583
+ }
584
+ /** On a Cloud home: a routine's report (its lifecycle card, carrying what
585
+ * the run said) of a routine the owner did not write, as it stands now. It
586
+ * brings someone else's instructions' results into the conversation it
587
+ * reports to. */
588
+ function cloudOthersRoutineReport(line) {
589
+ if (line.kind !== "routine.run" || !line.routineRun)
590
+ return false;
591
+ const routine = routines?.listRoutines().find((candidate) => candidate.id === line.routineRun.routineId);
592
+ return !routine || cloudRoutineAuthors?.authored(routine.id, routine) !== true;
593
+ }
594
+ /** On a Cloud home: a conversation of this bot that only its owner opened
595
+ * and wrote in (server/cloud-lending.ts ownerOnlyConversation), the only kind
596
+ * that may feed the bot's memory, daily log, recall or recent-work brief, or
597
+ * be quoted there by its title. Cached per thread until it gets another
598
+ * message, and dropped whenever a session is revoked. */
599
+ const ownerOnlyCache = new Map();
600
+ function cloudOwnerOnlyThread(threadId) {
601
+ if (cloudThreadStarters(threadId).some((person) => person !== undefined && !cloudProvenOwnerPerson(person)))
602
+ return false;
603
+ const thread = store.messagesFor(threadId);
604
+ const last = thread.at(-1);
605
+ const cached = ownerOnlyCache.get(threadId);
606
+ // A card answered later changes the answer too: key on its answerer as well.
607
+ const lastKey = last ? `${last.id}:${last.card?.answeredBy ? JSON.stringify(last.card.answeredBy) : ""}` : undefined;
608
+ if (cached && cached.length === thread.length && cached.last === lastKey)
609
+ return cached.owner;
610
+ const owner = ownerOnlyConversation(thread, cloudProvenOwnerPerson, cloudCardAnswerer) && !thread.some(cloudOthersRoutineReport);
611
+ if (ownerOnlyCache.size > 5_000)
612
+ ownerOnlyCache.clear();
613
+ ownerOnlyCache.set(threadId, { length: thread.length, last: lastKey, owner });
614
+ return owner;
615
+ }
616
+ /** On a Cloud home: a turn that is not provably the owner's starts for this
617
+ * bot (its memory is "pending" until the turn's generation ends:
618
+ * server/lending-memory.ts; see revokeInternalCapabilityGeneration). */
619
+ function noteForeignTurn(botId, threadId, generation) {
620
+ if (!lendingMemory)
621
+ return;
622
+ foreignTurns.set(generation, { botId, threadId });
623
+ // The record is updated in memory before it is saved: a failed save still
624
+ // leaves the bot pending, and must not fail the turn that is starting
625
+ // (whose end is what clears this entry).
626
+ try {
627
+ lendingMemory.noteForeignTurn(botId);
628
+ }
629
+ catch (error) {
630
+ console.warn(`lending: could not record a turn someone else started: ${error.message}`);
631
+ }
632
+ }
633
+ function foreignTurnRunning(botId) {
634
+ return [...foreignTurns.values()].some((turn) => turn.botId === botId);
635
+ }
636
+ /** Whose lent computers a status reader may see: on a Cloud home, only the
637
+ * owner's own devices (admin sessions) see the lent Mac, never a guest or a
638
+ * local process; elsewhere, the person behind the asking session. */
639
+ function sessionLendingPrincipal(auth) {
640
+ if (CLOUD_HOME)
641
+ return cloudOwnerSession(auth) ? CLOUD_HOME_LENDER : null;
642
+ return auth.kind === "session" ? personKey(auth.session) : null;
643
+ }
644
+ /** Profile fields only the owner may change on a Cloud home: what rides the
645
+ * bot's prompt, and whether it notifies the owner. */
646
+ const CLOUD_OWNER_PROFILE_FIELDS = ["name", "title", "description", "soul", "notifications"];
647
+ /** On a Cloud home: why this request may not act on a conversation, or
648
+ * null. The owner's conversations feed the owner's turns (and a lent Mac),
649
+ * so only one of the owner's own devices may rename them, change how they
650
+ * run, rewrite or delete them; anyone else only a conversation they started
651
+ * themselves (a guest's own session). Sends are checked by
652
+ * cloudGuestSendRefusal instead. Elsewhere always null. */
653
+ function cloudThreadRefusal(auth, threadId) {
654
+ if (!CLOUD_HOME || cloudOwnerSession(auth))
655
+ return null;
656
+ if (auth.kind === "session" && threadStarters.get(threadId) === actorKey(auth))
657
+ return null;
658
+ return "On this Cloud you can only change conversations you started.";
659
+ }
660
+ /** On a Cloud home: why a paired guest may not send into a conversation, or
661
+ * null. A guest writes only in conversations it started, never in the
662
+ * owner's (a line steered into the owner's running turn would reach it
663
+ * before anything could reclassify that turn). Local services (loopback)
664
+ * keep their own conversations; their lines make a conversation someone
665
+ * else's all the same (see noteSomeoneElsesLine). */
666
+ function cloudGuestSendRefusal(auth, threadId) {
667
+ if (!CLOUD_HOME || auth.kind !== "session" || cloudOwnerSession(auth))
668
+ return null;
669
+ return threadStarters.get(threadId) === actorKey(auth)
670
+ ? null : "On this Cloud you can only write in conversations you started. Start a new conversation with this bot.";
671
+ }
672
+ /** On a Cloud home: the one key every line, card answer, conversation and
673
+ * routine of the owner's own devices carries, whichever device and however
674
+ * often it was paired again, so re-pairing never demotes what the owner
675
+ * wrote. Only an admin session is given it: a guest keeps its own key. */
676
+ const CLOUD_OWNER_KEY = CLOUD_HOME ? `p_${createHash("sha256").update(`cloud-owner:${CLOUD_HOME.machineId}`).digest("base64url").slice(0, 22)}` : null;
677
+ /** …and the key of nobody at all: what a conversation made for work no
678
+ * person can be named for (a routine with no recorded writer) opens as. */
679
+ const CLOUD_NOBODY_KEY = `p_${createHash("sha256").update("cloud-nobody").digest("base64url").slice(0, 22)}`;
680
+ /** A Cloud home is personal (server/cloud-owner.ts): the keys its owner's
681
+ * devices carried before they shared one, settled at boot once the routines
682
+ * are loaded (settleCloudOwnership, below). `adopted` decides who opened a
683
+ * conversation, its level and its folder; only `proven` feeds lending and
684
+ * memory. Nothing is either until then. */
685
+ let cloudEarlierOwners = { adopted: new Set(), proven: new Set() };
686
+ /** Who a session acts as, for lines, openers and answers: on a Cloud home
687
+ * one of the owner's own devices is the owner; anyone else is themselves. */
688
+ function actorKey(auth) {
689
+ return CLOUD_OWNER_KEY && auth.scopes.includes("admin") ? CLOUD_OWNER_KEY : personKey(auth.session);
690
+ }
691
+ /** On a Cloud home: whoever is writing a routine in this request (routine
692
+ * creation and edits can open its results conversation). */
693
+ let routineWriterInFlight;
694
+ function withRoutineWriter(writer, work) {
695
+ const previous = routineWriterInFlight;
696
+ routineWriterInFlight = writer;
697
+ try {
698
+ return work();
699
+ }
700
+ finally {
701
+ routineWriterInFlight = previous;
702
+ }
703
+ }
704
+ /** On a Cloud home: a routine the owner approved (its card, or a proposal
705
+ * applied at once in their own Full-access conversation) is theirs as it
706
+ * stands: their key as its writer, their fingerprint on it. */
707
+ function cloudOwnersRoutine(routineId) {
708
+ const routine = routineId ? routines?.listRoutines().find((candidate) => candidate.id === routineId) : undefined;
709
+ if (!routine || !cloudRoutineAuthors || !CLOUD_OWNER_KEY)
710
+ return;
711
+ cloudRoutineAuthors.record(routine.id, routine);
712
+ cloudRoutineAuthors.wrote(routine.id, CLOUD_OWNER_KEY);
713
+ }
714
+ /** On a Cloud home: a routine that is the owner's as it stands: their
715
+ * fingerprint matches what it runs, or they are its writer and no
716
+ * fingerprint of theirs was ever recorded (a template, a restore). */
717
+ function cloudRoutineIsOwners(routineId) {
718
+ const routine = routineId ? routines?.listRoutines().find((candidate) => candidate.id === routineId) : undefined;
719
+ if (!routine || !cloudRoutineAuthors || !CLOUD_OWNER_KEY)
720
+ return false;
721
+ return cloudRoutineAuthors.authored(routine.id, routine) ||
722
+ (cloudRoutineAuthors.writer(routine.id) === CLOUD_OWNER_KEY && !cloudRoutineAuthors.recorded(routine.id));
723
+ }
724
+ /** On a Cloud home: a routine run that acts for the owner. Only in the run's
725
+ * own thread, and only while the owner's fingerprint matches what the run
726
+ * snapshotted, the same test as lending the Mac. A routine that is the
727
+ * owner's but not yet cleared for the Mac (no fingerprint: a v0.1.91
728
+ * approval, a template, a restore) can't clear itself or others this way. */
729
+ function cloudRoutineRunIsOwners(run, botId, threadId) {
730
+ if (!run || run.threadId !== threadId || store.taskByThread(botId, threadId)?.routineRunId !== run.id || !cloudRoutineAuthors)
731
+ return false;
732
+ const routine = routines?.listRoutines().find((candidate) => candidate.id === run.routineId);
733
+ return Boolean(routine) && cloudRoutineAuthors.authored(run.routineId, {
734
+ ...routine, prompt: run.prompt, target: run.target, botId: run.botId, groupId: run.groupId, attachments: run.attachments, runOn: run.runOn,
735
+ }) === true;
736
+ }
737
+ /** After the owner approved a routine request (its card, or at once in their
738
+ * own conversation): a routine it created is theirs; one it changed, paused
739
+ * or resumed stays theirs only if it already was (`wasOwners`, taken before
740
+ * the change). Approving a harmless change never makes anyone else's routine
741
+ * the owner's. */
742
+ function cloudOwnerApplied(action, routineId, wasOwners) {
743
+ if (action === "create" || ((action === "update" || action === "pause" || action === "resume") && wasOwners))
744
+ cloudOwnersRoutine(routineId);
745
+ }
746
+ /** What the owner saw on a card that proposed a routine, and what a routine
747
+ * runs, in the same terms (everything its fingerprint covers except an
748
+ * interval's anchor, which only moves when it runs). */
749
+ function approvalShape(shape) {
750
+ const schedule = shape.schedule && typeof shape.schedule === "object" ? { ...shape.schedule } : null;
751
+ if (schedule)
752
+ delete schedule.anchorAt;
753
+ return JSON.stringify([shape.prompt ?? "", shape.botId ?? "", shape.runOn ?? "maus", schedule, shape.target ?? "bot", shape.groupId ?? null,
754
+ (shape.attachments ?? []).map((attachment) => [attachment.id, attachment.path])]);
755
+ }
756
+ /** Who opens a routine's results conversation on a Cloud home: the writer
757
+ * of this request, else the owner for a routine that is theirs (they wrote
758
+ * it as it stands, or they are its writer: cloud-owner.ts), else its last
759
+ * writer, else nobody. */
760
+ function routineOpener(routineId) {
761
+ if (routineWriterInFlight)
762
+ return routineWriterInFlight;
763
+ const routine = routines?.listRoutines().find((candidate) => candidate.id === routineId);
764
+ if (routine && CLOUD_OWNER_KEY && cloudRoutineAuthors?.authored(routineId, routine))
765
+ return CLOUD_OWNER_KEY;
766
+ const writer = cloudRoutineAuthors?.writer(routineId);
767
+ if (writer && CLOUD_OWNER_KEY && writer === CLOUD_OWNER_KEY)
768
+ return CLOUD_OWNER_KEY;
769
+ return writer && !cloudOwnerPerson(writer) ? writer : CLOUD_NOBODY_KEY;
770
+ }
771
+ /** On a Cloud home: a conversation someone other than the owner opened (a
772
+ * guest, a guest's routine, or its room; on a personal Cloud, one a revoked
773
+ * session opened: nobody's). Its turns run in Ask, confined, in a private
774
+ * folder of their own (pinned at its first turn, or after it was unpinned at
775
+ * boot: see pinTaskCwd), and nothing in it reaches the owner's turns. */
776
+ function cloudGuestOpened(threadId) {
777
+ return Boolean(CLOUD_HOME) && cloudThreadStarters(threadId).some((person) => person !== undefined && !cloudOwnerPerson(person));
778
+ }
779
+ /** On a Cloud home: turns running now that a guest drives through another
780
+ * conversation (a teammate's handoff started from a guest's turn), by
781
+ * thread, with their generation. They are guest-driven like the guest's
782
+ * own conversation while they run (cloudGuestDriven). */
783
+ const guestDrivenTurns = new Map();
784
+ /** On a Cloud home: whether a handoff's chain started in (or passed
785
+ * through) a guest-driven conversation: work a guest's turn hands a
786
+ * teammate is the guest's too, however deep. */
787
+ function cloudGuestDrivenHandoff(nodeId) {
788
+ if (!CLOUD_HOME || !nodeId)
789
+ return false;
790
+ let node = roomHandoffs.nodes.get(nodeId);
791
+ for (let steps = 0; node && steps < 32; steps++) {
792
+ // Every conversation the work came through, up to the one it started in.
793
+ if (node.id !== nodeId && cloudGuestDriven(node.threadId))
794
+ return true;
795
+ if (node.id === node.rootId)
796
+ break;
797
+ node = roomHandoffs.nodes.get(node.parentId ?? node.rootId);
798
+ }
799
+ return false;
800
+ }
801
+ /** Who a conversation a bot opens from another is for: on a Cloud home,
802
+ * never the owner when a guest drives the source (it opens as the guest's,
803
+ * or nobody's), so it is confined like the source. */
804
+ function openerFrom(fromThreadId) {
805
+ const person = threadPersonKey(fromThreadId);
806
+ if (!CLOUD_HOME || !cloudGuestDriven(fromThreadId))
807
+ return person;
808
+ return person && !cloudOwnerPerson(person) ? person : CLOUD_NOBODY_KEY;
809
+ }
810
+ /** The model a thread a bot opens from one of its own threads starts on:
811
+ * that thread's, not the bot default the person may have moved away from
812
+ * (#1950). Only the bot's own thread counts — a room or a teammate's thread
813
+ * is not one of its tasks, and another bot's model belongs to another engine.
814
+ * Approval stays the bot's, except that a level the inherited engine would
815
+ * have to confirm on a model switch starts the new thread in Ask. */
816
+ function parentThreadModel(botId, parentThreadId) {
817
+ // The stored profile, never a per-thread projection: the comparison is
818
+ // against the defaults the new thread would otherwise get.
819
+ const bot = store.bot(botId);
820
+ const modelSelection = bot && store.taskByThread(bot.id, parentThreadId)?.modelSelection;
821
+ if (!bot || !modelSelection)
822
+ return {};
823
+ const needsAsk = modelSwitchNeedsAsk(approvalModeFor(bot), registry.cliTarget(bot.modelSelection.instanceId)?.driverKind, registry.cliTarget(modelSelection.instanceId)?.driverKind);
824
+ return { modelSelection, ...(needsAsk ? { approvalMode: "ask" } : {}) };
825
+ }
826
+ /** On a Cloud home: the conversation that work a guest-driven turn hands a
827
+ * teammate (delegate_bot, ask_bot) runs in. It is a fresh one of the guest's
828
+ * own on the teammate, never the owner's conversation with it, so the work,
829
+ * and anything that later continues there, stays confined. Undefined when
830
+ * the source is not guest-driven, null when it could not be made. */
831
+ function guestWorkThread(from, target, fromThreadId, brief) {
832
+ if (!CLOUD_HOME || !cloudGuestDriven(fromThreadId))
833
+ return undefined;
834
+ const title = brief.replace(/\s+/g, " ").trim().slice(0, 60) || `From ${from.name}`;
835
+ const task = store.createTask(target.id, title, false, undefined, { botId: from.id, name: from.name, at: Date.now() });
836
+ if (!task)
837
+ return null;
838
+ threadStarters.set(task.threadId, openerFrom(fromThreadId));
839
+ return task.threadId;
840
+ }
841
+ /** On a personal Cloud home the only turns confined like a guest's
842
+ * (cloudGuestDriven) are in what came before it: a routine nobody is proven
843
+ * to have written, or a conversation or room a revoked session opened. What
844
+ * the owner is told about one, and when its engine cannot be confined. */
845
+ const ROUTINE_FROM_BEFORE = "This routine was made before this update. Open it and save it once to run it with full access.";
846
+ 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.";
847
+ function confinedWhy(threadId) {
848
+ const run = activeRoutineRunForThread(threadId);
849
+ const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
850
+ return run && writer && !cloudOwnerPerson(writer) ? ROUTINE_FROM_BEFORE : CONVERSATION_FROM_BEFORE;
851
+ }
852
+ function guestEngineRefusal(threadId) {
853
+ const why = confinedWhy(threadId);
854
+ return why === ROUTINE_FROM_BEFORE ? `This bot's engine can't run it that way. ${why}`
855
+ : "This conversation is from before your Cloud was only yours, and this bot's engine can't work in it. Start a new conversation.";
856
+ }
857
+ /** On a Cloud home: a turn a guest drives, which runs in Ask whatever the
858
+ * bot's own level (no Full access, no Auto reviewer, no saved commands), with
859
+ * no shell (guestEngineRefusal): a conversation a guest opened, a run of a
860
+ * routine a guest wrote, work a guest's turn handed on (guestDrivenTurns), or
861
+ * a room whose latest line from a person is a guest's. */
862
+ function cloudGuestDriven(threadId) {
863
+ if (!CLOUD_HOME)
864
+ return false;
865
+ if (guestDrivenTurns.has(threadId) || cloudGuestOpened(threadId))
866
+ return true;
867
+ // A run of a routine a guest wrote (or last edited) is the guest's.
868
+ const run = activeRoutineRunForThread(threadId);
869
+ const writer = run ? cloudRoutineAuthors?.writer(run.routineId) : undefined;
870
+ if (writer && !cloudOwnerPerson(writer))
871
+ return true;
872
+ if (!store.groupByThread(threadId))
873
+ return false;
874
+ const person = store.messagesFor(threadId).findLast((line) => line.role === "user" && !line.peerAsk && line.sender?.id)?.sender?.id;
875
+ return person !== undefined && !cloudOwnerPerson(person);
876
+ }
877
+ /** On a Cloud home: a request from one of the owner's own devices. The
878
+ * Admin's signed pairing gives the desktop an admin session; a guest the
879
+ * owner pairs with chat-only access has client scope. Loopback is not the
880
+ * owner here: nothing the person does reaches a Cloud home that way, only
881
+ * processes on the machine (a bot's shell) do. */
882
+ function cloudOwnerSession(auth) {
883
+ return auth.kind === "session" && auth.scopes.includes("admin");
884
+ }
885
+ /** On a Cloud home: whether a person key is the owner's, for who opened a
886
+ * conversation, who drives a turn, its approval level and its folder: the
887
+ * owner's one key, a device of theirs still paired, or a key from before
888
+ * they shared one (cloud-owner.ts, adopted). */
889
+ function cloudOwnerPerson(person) {
890
+ if (!person)
891
+ return false;
892
+ if (person === CLOUD_OWNER_KEY || cloudEarlierOwners.adopted.has(person))
893
+ return true;
894
+ return cloudOwnerDevice(person);
895
+ }
896
+ /** …and whether it is proven the owner's, the only kind whose words may
897
+ * reach the lent Mac, memory, recall and the recent-work brief: a key from
898
+ * before counts only with proof it was the owner's (cloud-owner.ts, proven),
899
+ * never just because nobody revoked it. */
900
+ function cloudProvenOwnerPerson(person) {
901
+ if (!person)
902
+ return false;
903
+ if (person === CLOUD_OWNER_KEY || cloudEarlierOwners.proven.has(person))
904
+ return true;
905
+ return cloudOwnerDevice(person);
906
+ }
907
+ /** Written before the owner had one key: a device of theirs still paired. */
908
+ function cloudOwnerDevice(person) {
909
+ return sessions.list().some((session) => session.scopes.includes("admin") && personKey(session) === person);
910
+ }
911
+ /** On a Cloud home, a session that is not one of the owner's own devices may
912
+ * not answer a card of any kind. */
913
+ function cloudCardRefusal(auth) {
914
+ return auth.scopes.includes("admin") ? null : "Only the owner of this Cloud can answer this card.";
915
+ }
441
916
  /** Whose session may answer a card. The provider CLI's own approval modes and
442
917
  * the harness's proposals stay exactly as they are; this adds no card, gate
443
918
  * or prompt, it only decides whose answer to an existing card counts.
@@ -454,6 +929,11 @@ function cardAnswerRefusal(auth, threadId, requestId, behavior) {
454
929
  ? "A local service can only decline this request. Approve or answer it in OpenMausBot while signed in."
455
930
  : null;
456
931
  }
932
+ // A Cloud home is one person's: a device paired with chat-only access (a
933
+ // guest) may read along but never answer, or its words would reach the
934
+ // owner's turn, and through it a lent Mac (docs/cloud-pro.md).
935
+ if (CLOUD_HOME)
936
+ return cloudCardRefusal(auth);
457
937
  if (auth.scopes.includes("admin") || !sharedMembership())
458
938
  return null;
459
939
  const known = [threadStarters.get(threadId), cardRequesterKey(threadId, requestId)].filter((person) => Boolean(person));
@@ -497,7 +977,19 @@ function decisionActorFor(auth) {
497
977
  function cardAnswererFor(auth) {
498
978
  if (auth.kind === "loopback")
499
979
  return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
500
- return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user" };
980
+ return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user",
981
+ ...(CLOUD_HOME ? { person: actorKey(auth) } : {}) };
982
+ }
983
+ /** On a Cloud home, who is answering a card right now, by request id: the
984
+ * answer reaches the turn before `answeredBy` is written, and a lending turn
985
+ * must know whose words it just received (server/cloud-lending.ts). */
986
+ const cloudCardAnswersInFlight = new Map();
987
+ /** The person who answered a card, as far as lending on a Cloud home is
988
+ * concerned: the recorded answerer, else whoever is answering it now. */
989
+ function cloudCardAnswerer(card) {
990
+ if (card.answeredBy)
991
+ return card.answeredBy.kind === "session" ? card.answeredBy.person : undefined;
992
+ return card.requestId ? cloudCardAnswersInFlight.get(card.requestId) : undefined;
501
993
  }
502
994
  /** Answer a card as `auth`: the decision rows written meanwhile name the
503
995
  * answerer, and a card this answer settled records who settled it. A card
@@ -507,6 +999,8 @@ async function answeringCardAs(auth, threadId, requestId, work) {
507
999
  const card = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId)?.card;
508
1000
  return Boolean(card && !card.answered && !card.dismissed && !card.expired);
509
1001
  })();
1002
+ if (CLOUD_HOME && open)
1003
+ cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? actorKey(auth) : undefined);
510
1004
  try {
511
1005
  await withDecisionActor(decisionActorFor(auth), work);
512
1006
  }
@@ -516,6 +1010,8 @@ async function answeringCardAs(auth, threadId, requestId, work) {
516
1010
  if (message && card && !card.answeredBy && card.answered !== "unavailable" && (card.answered || card.dismissed)) {
517
1011
  store.patchMessage(threadId, message.id, { card: { ...card, answeredBy: cardAnswererFor(auth) } });
518
1012
  }
1013
+ if (CLOUD_HOME && open)
1014
+ cloudCardAnswersInFlight.delete(requestId);
519
1015
  }
520
1016
  }
521
1017
  // ── bot visibility (server/bot-visibility.ts) ──────────────────────────
@@ -631,6 +1127,11 @@ function settleRoomFloor(group) {
631
1127
  return;
632
1128
  store.patchGroup(group.id, { audienceFloor: next === "everyone" ? undefined : next });
633
1129
  }
1130
+ /** On a Cloud home the recent-work brief draws only on conversations the
1131
+ * owner alone wrote in (see cloudOwnerOnlyThread). */
1132
+ function recentWorkFilter() {
1133
+ return CLOUD_HOME ? { include: cloudOwnerOnlyThread } : {};
1134
+ }
634
1135
  /** The conversations a bot's recent-work brief may draw on: its own, and the
635
1136
  * rooms roomFeedsBot allows. */
636
1137
  function recentWorkSources(bot) {
@@ -645,7 +1146,7 @@ function recentWorkSources(bot) {
645
1146
  * or a room must not be able to pull a private chat into its reply. The
646
1147
  * conversations are the ones session_search would search, minus this one. */
647
1148
  function autoRecallPrompt(bot, threadId, message, opts) {
648
- if (!autoRecallEnabled(cfg))
1149
+ if (bot.memoryEnabled === false || !autoRecallEnabled(cfg))
649
1150
  return "";
650
1151
  const roomByThread = new Map();
651
1152
  if (opts.conversations) {
@@ -658,7 +1159,10 @@ function autoRecallPrompt(bot, threadId, message, opts) {
658
1159
  }
659
1160
  }
660
1161
  const threadIds = opts.conversations
661
- ? [...new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])].filter((id) => id !== threadId)
1162
+ ? [...new Set([bot.threadId, ...(bot.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])]
1163
+ // On a Cloud home a conversation anyone else wrote in never feeds
1164
+ // another: its words would reach the owner's turns (and a lent Mac).
1165
+ .filter((id) => id !== threadId && (!CLOUD_HOME || cloudOwnerOnlyThread(id)))
662
1166
  : [];
663
1167
  try {
664
1168
  const recalled = buildRecall({
@@ -771,7 +1275,7 @@ function adminAuditPlan(method, path) {
771
1275
  if (path.startsWith("/api/internal/") || path.startsWith("/api/testing/"))
772
1276
  return null;
773
1277
  const plan = {
774
- config: requiredScope(method, path, { sharedComputers: sharedComputersEnabled(cfg) }) === "admin",
1278
+ config: requiredScope(method, path, { sharedComputers: lendingEnabled() }) === "admin",
775
1279
  createsBots: method === "POST" && (path === "/api/bots" || path === "/api/teams/import" || path === "/api/org-library/add"),
776
1280
  createsWebhook: method === "POST" && path === "/api/webhooks",
777
1281
  pairing: method === "POST" && path === "/api/auth/pairing",
@@ -790,11 +1294,13 @@ function adminAuditPlan(method, path) {
790
1294
  m = /^\/api\/groups\/([\w-]+)$/.exec(path);
791
1295
  if (m && method === "PATCH")
792
1296
  plan.roomId = m[1];
793
- m = /^\/api\/instances\/([\w.-]+)\/(install|auth\/complete|auth\/sign-out|claude-update)$/.exec(path);
1297
+ m = /^\/api\/instances\/([\w.-]+)\/(install|auth\/complete|auth\/sign-out|claude-update|leftover-files\/remove)$/.exec(path);
794
1298
  if (m && method === "POST")
795
1299
  plan.engineAction = { id: m[1], action: m[2].replace("/", "-") };
796
1300
  if (method === "POST" && path === "/api/instances/claude-accounts")
797
1301
  plan.engineAction = { id: "claude", action: "account-add" };
1302
+ if (method === "POST" && path === "/api/instances/chatgpt-accounts")
1303
+ plan.engineAction = { id: "chatgpt", action: "account-add" };
798
1304
  return plan.config || plan.botId || plan.createsBots || plan.deletesBot || plan.createsWebhook || plan.webhookId || plan.sessionId || plan.pairing || plan.engineAction || plan.roomId ? plan : null;
799
1305
  }
800
1306
  const WEBHOOK_AUDIT_FIELDS = ["name", "prompt", "botId", "runOn", "enabled", "eventTypes"];
@@ -969,6 +1475,46 @@ function queuedTurnTrigger(item) {
969
1475
  return item.sender ? { kind: "user", label: item.sender.name } : { kind: "owner" };
970
1476
  }
971
1477
  const providerAuthSessions = new ProviderAuthSessions();
1478
+ /** OAuth sign-in for URL servers; tokens live in their own owner-only file. */
1479
+ const mcpOAuth = new McpOAuthManager({
1480
+ file: join(DATA_DIR, "mcp-oauth.json"),
1481
+ isOwnerLive: (owner) => owner === "loopback" || sessions.isLive(owner),
1482
+ // Read the registered app when needed; never copy its secret into the token store.
1483
+ clients: (name, url) => {
1484
+ const raw = cfg.mcpServers?.[name];
1485
+ const parsed = raw === undefined ? null : parseStoredMcpServer(name, raw);
1486
+ return parsed?.ok && isRemoteMcpServer(parsed.server) && parsed.server.url === url ? parsed.server.oauth : undefined;
1487
+ },
1488
+ });
1489
+ // OpenCode reads provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, …) from
1490
+ // its environment, as it does in a terminal, but only where that environment
1491
+ // is the person's own shell (openCodeProviderKeysAllowed). Wired before the
1492
+ // first catalog probe below. Company enrollment arrives later from the
1493
+ // desktop parent and is wired in once it can (openCodeOrganisationManaged).
1494
+ let openCodeOrganisationManaged = () => false;
1495
+ const openCodeKeysAllowed = () => openCodeProviderKeysAllowed({
1496
+ cloudHome: Boolean(CLOUD_HOME),
1497
+ hostedWorkspace: HOSTED_WORKSPACE,
1498
+ organisationManaged: openCodeOrganisationManaged(),
1499
+ sharedSignIn: sharedSignIn(signInAllowList()),
1500
+ });
1501
+ setOpenCodeProviderKeyPolicy(openCodeKeysAllowed);
1502
+ let openCodeKeysLastAllowed = openCodeKeysAllowed();
1503
+ /** OpenCode lists a provider's models only while it may read that
1504
+ * provider's key. When enrollment or the sign-in list changes the answer,
1505
+ * list them again, so the picker never offers models its turns lack. */
1506
+ function resyncOpenCodeProviderKeys() {
1507
+ const allowed = openCodeKeysAllowed();
1508
+ if (allowed === openCodeKeysLastAllowed)
1509
+ return;
1510
+ openCodeKeysLastAllowed = allowed;
1511
+ for (const instance of registry.instances()) {
1512
+ if (instance.driverKind !== "opencodeGo")
1513
+ continue;
1514
+ void registry.refreshModels(instance.instanceId)
1515
+ .then(() => broadcast({ kind: "config", ...configStatus() }), () => { });
1516
+ }
1517
+ }
972
1518
  await registry.load(providerConfigs(), decorateHostedProvider);
973
1519
  const bundledSkills = loadBundledSkills();
974
1520
  const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
@@ -1067,7 +1613,22 @@ let companyRuntimeReady;
1067
1613
  const companyRuntimeStarted = new Promise(resolve => { companyRuntimeReady = resolve; });
1068
1614
  /** The enrolled organisation's desktop policy: in memory only, read-only, and
1069
1615
  * inert (null) unless Electron's enrolled parent sends one. */
1070
- const managedPolicy = new ManagedDesktopPolicy({ onChange: () => broadcast({ kind: "config", ...configStatus() }) });
1616
+ const managedPolicy = new ManagedDesktopPolicy({ onChange: () => {
1617
+ broadcast({ kind: "config", ...configStatus() });
1618
+ resyncOpenCodeProviderKeys();
1619
+ } });
1620
+ /** A computer kind this server will not use, refused before anything is
1621
+ * prepared: a Cloud home never offers this computer or a Local VM
1622
+ * (cloud-home.ts), and an enrolled organisation may disallow any kind. */
1623
+ function computerPlaceRefusal(kind) {
1624
+ const cloudHome = CLOUD_HOME ? cloudHomePlaceRefusal(kind === "thisComputer" ? "local" : kind === "localVm" ? "vm" : "cloud") : undefined;
1625
+ if (cloudHome)
1626
+ return { message: cloudHome, code: "cloud_home" };
1627
+ const managed = managedPolicy.computerRefusal(kind);
1628
+ return managed ? { message: managed, code: "managed_policy" } : undefined;
1629
+ }
1630
+ /** Cloud chosen with no Boat account: a Cloud home suggests the browser, not a Local VM. */
1631
+ const BOAT_NOT_CONFIGURED = boatNotConfiguredMessage(Boolean(CLOUD_HOME));
1071
1632
  /** Why the organisation refuses this instance for bots, or undefined. */
1072
1633
  function policyModelRefusal(instance) {
1073
1634
  const engine = BUILT_IN_DRIVERS.find(driver => driver.driverKind === instance.driverKind)?.metadata.displayName;
@@ -1079,7 +1640,10 @@ const managedDesktop = new ManagedDesktopProviders({
1079
1640
  beforeReplace: stopCompanyInstances,
1080
1641
  // Ids became stable across re-enrolment; carry old references forward once.
1081
1642
  migrate: aliases => { store.renameInstances(new Map(aliases.map(({ from, to }) => [from, to]))); },
1082
- onAvailability: () => broadcast({ kind: "config", ...configStatus() }),
1643
+ onAvailability: () => {
1644
+ broadcast({ kind: "config", ...configStatus() });
1645
+ resyncOpenCodeProviderKeys();
1646
+ },
1083
1647
  afterReplace: ids => {
1084
1648
  for (const id of providerInstancesChanging)
1085
1649
  if (managedDesktop.owns(id))
@@ -1088,8 +1652,11 @@ const managedDesktop = new ManagedDesktopProviders({
1088
1652
  // Existing renderer config events refresh /api/instances as well, so
1089
1653
  // Company grants and revocations appear without reloading the window.
1090
1654
  broadcast({ kind: "config", ...configStatus() });
1655
+ resyncOpenCodeProviderKeys();
1091
1656
  },
1092
1657
  });
1658
+ // An organisation-managed desktop's environment is not the person's shell.
1659
+ openCodeOrganisationManaged = () => managedPolicy.current() !== null || managedDesktop.enrolled();
1093
1660
  utilityParentPort?.on("message", event => {
1094
1661
  const message = event.data;
1095
1662
  if (message?.type !== "openmausbot:managed-desktop-identity")
@@ -1212,7 +1779,27 @@ function mintInternalCapability(capability) {
1212
1779
  });
1213
1780
  return token;
1214
1781
  }
1782
+ /** A turn that was not provably the owner's has ended: judge its bot's
1783
+ * memory now, so a change it made is caught and an unchanged one is cleared
1784
+ * (server/lending-memory.ts). `generation` undefined: every such turn on the
1785
+ * thread (a teardown that settles threads, not generations). */
1786
+ function endForeignTurns(threadId, generation) {
1787
+ for (const [id, foreign] of foreignTurns) {
1788
+ if (foreign.threadId !== threadId || (generation !== undefined && id !== generation))
1789
+ continue;
1790
+ foreignTurns.delete(id);
1791
+ try {
1792
+ lendingMemory?.reconcile(foreign.botId, foreignTurnRunning(foreign.botId));
1793
+ }
1794
+ catch (error) {
1795
+ console.warn(`lending: could not check a bot's memory: ${error.message}`);
1796
+ }
1797
+ }
1798
+ }
1215
1799
  function revokeInternalCapabilityGeneration(threadId, generation) {
1800
+ endForeignTurns(threadId, generation);
1801
+ if (guestDrivenTurns.get(threadId) === generation)
1802
+ guestDrivenTurns.delete(threadId);
1216
1803
  for (const [token, capability] of internalCapabilities) {
1217
1804
  if (capability.threadId === threadId && capability.generation === generation) {
1218
1805
  internalCapabilities.delete(token);
@@ -1239,6 +1826,9 @@ function revokeInternalCapabilitiesForThread(threadId) {
1239
1826
  function revokeAllInternalCapabilities() {
1240
1827
  computerSelectionTurns.clear();
1241
1828
  internalCapabilities.clear();
1829
+ // foreignTurns stays: a turn that is not provably the owner's can still
1830
+ // write while its engine is torn down, and its own end (which revokes its
1831
+ // generation by id) is what judges it (server/lending-memory.ts).
1242
1832
  activeInternalGenerationByThread.clear();
1243
1833
  internalGenerationByProviderTurn.clear();
1244
1834
  }
@@ -1350,9 +1940,13 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
1350
1940
  OMB_ROOM_TURN: roomCoordination ? "1" : "0",
1351
1941
  OMB_OWN_THREAD_CREATION: ownThreadCreation ? "1" : "0",
1352
1942
  OMB_SKILL_AUTHORING_ENABLED: skillAuthoring ? "1" : "0",
1943
+ OMB_MEMORY_ENABLED: store.bot(botId)?.memoryEnabled === false ? "0" : "1",
1353
1944
  // The shared-computer tools are advertised only while the workspace
1354
1945
  // gate is on; the routes behind them refuse regardless.
1355
- OMB_SHARED_COMPUTERS_ENABLED: sharedComputersEnabled(cfg) ? "1" : "0",
1946
+ OMB_SHARED_COMPUTERS_ENABLED: lendingEnabled() ? "1" : "0",
1947
+ // A Cloud home offers no this computer and no Local VM (cloud-home.ts),
1948
+ // so select_computer lists neither and vm_exec is not shown.
1949
+ OMB_CLOUD_HOME: CLOUD_HOME ? "1" : "0",
1356
1950
  // Same capability rule for voice: the tool is offered only when this
1357
1951
  // bot can actually speak, and the route re-checks on every call.
1358
1952
  OMB_VOICE_NOTES: (() => {
@@ -1603,11 +2197,11 @@ function releaseTurnResources(owner) {
1603
2197
  }
1604
2198
  }
1605
2199
  async function bindTurnComputer(owner, resource, exclusive = false) {
1606
- // Every computer claim passes here: a kind the organisation disallows is
1607
- // refused at claim time, whichever path selected it.
1608
- const kind = computerKindForResource(resource), refusal = kind && managedPolicy.computerRefusal(kind);
2200
+ // Every computer claim passes here: a kind the organisation disallows, or a
2201
+ // Cloud home never offers, is refused at claim time, whichever path selected it.
2202
+ const kind = computerKindForResource(resource), refusal = kind && computerPlaceRefusal(kind);
1609
2203
  if (refusal)
1610
- throw Object.assign(new Error(refusal), { code: "managed_policy" });
2204
+ throw Object.assign(new Error(refusal.message), { code: refusal.code });
1611
2205
  const active = () => activeInternalGenerationByThread.get(owner.threadId) === owner.generation &&
1612
2206
  turnResourceOwners.get(owner.threadId)?.generation === owner.generation;
1613
2207
  let waitingMessage;
@@ -1775,7 +2369,15 @@ function canAdmitDirectTurn(botId, threadId) {
1775
2369
  /** Routine and webhook dispatch shares startTurn's admission preconditions
1776
2370
  * instead of waiting for whole-bot idleness: a free thread slot and no
1777
2371
  * active group turn. A group turn blocks scheduled starts the same way it
1778
- * blocks every other turn kind; it does not consume a capacity slot. */
2372
+ * blocks every other turn kind; it does not consume a capacity slot.
2373
+ *
2374
+ * A fresh dispatch also pins to the bot's own project folder, exactly like
2375
+ * startTurn's cwd resolution: a free thread slot alone can miss a second
2376
+ * task of this bot landing in that one folder (#F-collide). Predicting that
2377
+ * collision here lets it defer through the same busy-target machinery as
2378
+ * capacity and group-turn contention, instead of reaching startTurn's own
2379
+ * workspace claim — which throws deep inside a detached dispatch that no
2380
+ * caller here awaits, so the run would otherwise fail permanently. */
1779
2381
  function unattendedDispatchState(botId) {
1780
2382
  const bot = store.bot(botId);
1781
2383
  const decision = admit("unattended", {}, {
@@ -1783,7 +2385,19 @@ function unattendedDispatchState(botId) {
1783
2385
  atCapacity: Boolean(bot) && botAtThreadCapacity(botId),
1784
2386
  groupTurn: Boolean(bot) && Boolean(activeGroupTurnForBot(botId)),
1785
2387
  });
1786
- return decision.action !== "refuse" ? "ready" : decision.code === "missing" ? "missing" : "busy";
2388
+ if (decision.action === "refuse")
2389
+ return decision.code === "missing" ? "missing" : "busy";
2390
+ if (bot?.cwd) {
2391
+ try {
2392
+ if (turnResources.blocker(workspaceResource(bot.cwd), { threadId: "", generation: "" }))
2393
+ return "busy";
2394
+ }
2395
+ catch {
2396
+ // An unresolvable folder is startTurn's own admission check to
2397
+ // report; this prediction only ever adds a defer, never a refusal.
2398
+ }
2399
+ }
2400
+ return "ready";
1787
2401
  }
1788
2402
  function requestedTaskBot(botId, rawThreadId) {
1789
2403
  const profile = store.bot(botId);
@@ -1949,7 +2563,16 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
1949
2563
  /** The bot's browser for this turn: agent-browser, one isolated session per
1950
2564
  * browser profile or per bot (docs/plans/browser-engine.md). Null, with the
1951
2565
  * reason logged once, when the engine is not on this machine. */
1952
- const browserRuntime = new BrowserRuntime();
2566
+ const browserRuntime = new BrowserRuntime({
2567
+ // The bot's restart_browser tool. An open Browser panel watched the old
2568
+ // browser; drop its stream the way the panel's own Restart button does.
2569
+ closeBrowser: async (session, spec) => {
2570
+ const closed = await closeBrowserSession(spec.command, browserRuntimeEnv({ ...spec.env, AGENT_BROWSER_SESSION: session }));
2571
+ if (closed)
2572
+ browserLive.closeForSession(session);
2573
+ return closed;
2574
+ },
2575
+ });
1953
2576
  const browserLive = new BrowserLive({ runtime: browserRuntime });
1954
2577
  // Temporary profiles last for this server run, but are never saved to disk.
1955
2578
  // The viewer and the agent must address the SAME temporary browser.
@@ -2464,6 +3087,13 @@ function checkedGroupResponder(value, memberIds) {
2464
3087
  return { kind: "everyone" };
2465
3088
  if (responder.kind === "mentions")
2466
3089
  return { kind: "mentions" };
3090
+ if (responder.kind === "auto") {
3091
+ if (responder.fallbackBotId === undefined || responder.fallbackBotId === null)
3092
+ return { kind: "auto" };
3093
+ return typeof responder.fallbackBotId === "string" && memberIds.includes(responder.fallbackBotId)
3094
+ ? { kind: "auto", fallbackBotId: responder.fallbackBotId }
3095
+ : null;
3096
+ }
2467
3097
  if (responder.kind === "member" &&
2468
3098
  typeof responder.botId === "string" &&
2469
3099
  memberIds.includes(responder.botId)) {
@@ -2619,7 +3249,8 @@ function previewSystemPrompt(bot) {
2619
3249
  { id: "plan", label: "Surface", text: previewPlan.computer === undefined ? "" : surfacePrompt({
2620
3250
  computer: previewPlan.computer && previewPlan.computer !== "off" && computerPromptKind ? previewPlan.computer : null,
2621
3251
  browser: previewPlan.computer === undefined ? false : previewPlan.browser,
2622
- }, { note: previewPlan.note }) },
3252
+ }, { note: previewPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
3253
+ { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(agentsMounted && lendingEnabled()) : "" },
2623
3254
  { id: "composio", label: "Connected apps", text: caps?.composioMcp && bot.composio !== false && composio.configured(cfg) ? composioSystemPrompt(bot.connectorTools) : "" },
2624
3255
  { id: "mcp", label: "MCP servers", text: caps?.customMcp ? customMcpPrompt(Object.keys(engineMcpServers(bot))) : "" },
2625
3256
  { id: "browser", label: "Browser", text: previewPlan.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
@@ -2628,7 +3259,7 @@ function previewSystemPrompt(bot) {
2628
3259
  { id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
2629
3260
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
2630
3261
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
2631
- { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace) }) },
3262
+ { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: agentsMounted, fileTools: Boolean(privateWorkspace), enabled: bot.memoryEnabled !== false }) },
2632
3263
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
2633
3264
  ]);
2634
3265
  const totalBytes = built.sections.reduce((n, s) => n + s.bytes, 0);
@@ -2701,6 +3332,7 @@ async function botOverview(bot) {
2701
3332
  })),
2702
3333
  engine,
2703
3334
  browserEnabled: builtInBrowserEnabled(cfg),
3335
+ cloudHome: Boolean(CLOUD_HOME),
2704
3336
  connectedApps,
2705
3337
  sectionPeers,
2706
3338
  timeZone,
@@ -2715,7 +3347,12 @@ async function botOverview(bot) {
2715
3347
  * with one deliberate exception: a Chief of Staff with Full access makes the
2716
3348
  * threads it delegates Full too (delegatedFullAccess), so the grant the
2717
3349
  * person gave the Chief covers the work the Chief hands out. */
2718
- const approvalModeForTurn = (bot, peerInitiated = false) => {
3350
+ const approvalModeForTurn = (bot, peerInitiated = false, threadId = bot.threadId) => {
3351
+ // On a Cloud home a turn a guest drives runs in Ask, whatever the bot's
3352
+ // own level. Judged by the conversation the turn runs in (a room's, for a
3353
+ // room turn), never by whichever of the bot's conversations is active.
3354
+ if (cloudGuestDriven(threadId))
3355
+ return "ask";
2719
3356
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
2720
3357
  if (!supportsApprovalMode(bot.modelSelection, mode)) {
2721
3358
  return "ask";
@@ -2730,7 +3367,7 @@ function fullAccessForSource(botId, threadId) {
2730
3367
  return false;
2731
3368
  const bot = store.projectBotForTask(botId, threadId) ?? owner.bot;
2732
3369
  // Origin changes Custom to Auto, never Full; no live-turn state is needed.
2733
- return approvalModeForTurn(bot) === "full";
3370
+ return approvalModeForTurn(bot, false, threadId) === "full";
2734
3371
  }
2735
3372
  function peerReviewRequired(bot, threadId) {
2736
3373
  return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
@@ -2754,8 +3391,7 @@ function delegatedFullAccess(from, fromThreadId, target) {
2754
3391
  }
2755
3392
  /** Make a delegated thread Full and say so in it once, so the level the
2756
3393
  * chip shows and the level the turns run at agree, and the person can see
2757
- * where the access came from. Idempotent: a pair conversation is reused
2758
- * across delegations and must not collect a chip per request. */
3394
+ * where the access came from. */
2759
3395
  function grantDelegatedFullAccess(from, target, threadId) {
2760
3396
  if (store.taskByThread(target.id, threadId)?.approvalMode === "full")
2761
3397
  return;
@@ -2769,13 +3405,16 @@ function grantDelegatedFullAccess(from, target, threadId) {
2769
3405
  /** A room member's level for one turn. Work a Full-access Chief hands out
2770
3406
  * in a room runs Full for that turn: the room thread is shared, so the
2771
3407
  * level is not stored on it — it rides the handoff. */
2772
- function roomTurnApprovalMode(bot, orchestration) {
3408
+ function roomTurnApprovalMode(bot, threadId, orchestration) {
3409
+ // On a Cloud home a room turn a guest drives runs in Ask (cloudGuestDriven).
3410
+ if (cloudGuestDriven(threadId))
3411
+ return "ask";
2773
3412
  const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
2774
3413
  const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
2775
3414
  const from = source ? store.bot(source.botId) : undefined;
2776
3415
  if (from && source && delegatedFullAccess(from, source.threadId, bot))
2777
3416
  return "full";
2778
- return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId));
3417
+ return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
2779
3418
  }
2780
3419
  /** Privileged approval-mode transitions are deliberately absent from the
2781
3420
  * loopback HTTP authority model: a bot with shell access can curl that
@@ -3275,7 +3914,9 @@ function createChannel(value) {
3275
3914
  throw Object.assign(new Error("invalid setup.defaultResponder"), { status: 400 });
3276
3915
  setup = { bulletin: requested.bulletin, defaultResponder: responder, completed: true };
3277
3916
  }
3278
- return store.createGroup(name, memberIds, false, section, setup);
3917
+ // A new room with no routing of its own starts on Auto while the decision
3918
+ // model is on for rooms; otherwise it gets today's default (first member).
3919
+ return store.createGroup(name, memberIds, false, section, setup ?? (deciderReady(cfg, "roomRouting") ? { defaultResponder: { kind: "auto" } } : undefined));
3279
3920
  }
3280
3921
  function updateChannel(groupId, value) {
3281
3922
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -3530,6 +4171,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3530
4171
  : child.status === "completed" ? `${bot?.name ?? "Teammate"} replied${child.groupId ? ` · ${store.group(child.groupId)?.name ?? "Room"}` : ""}`
3531
4172
  : `${bot?.name ?? "Teammate"} — ${child.status}: ${child.result.slice(0, 180)}`,
3532
4173
  ok: child.status === "completed" && !problem,
4174
+ // The chip label stays short; the report itself rides along so a
4175
+ // phone can show what the teammate said without opening its thread.
4176
+ ...(child.status === "completed" && !problem && child.result.trim()
4177
+ ? { output: redactSecretsInText(child.result.trim()).slice(0, 2_000) } : {}),
3533
4178
  },
3534
4179
  ...(child.groupId ? { comm: { groupId: child.groupId, threadId: child.threadId, withBotId: child.botId,
3535
4180
  withName: bot?.name ?? "Teammate", withColor: bot?.color ?? "blue" } }
@@ -3543,14 +4188,9 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3543
4188
  if (source)
3544
4189
  markTaskContextExternallyUpdated(source, parent.threadId);
3545
4190
  }
3546
- // A work thread exists only because the pair conversation was busy with
3547
- // another job. Its result is now in the sender's conversation, so it
3548
- // closes itself exactly as close_thread would — folded out of the
3549
- // sidebar, never deleted, and open again the moment anyone speaks
3550
- // there. A finished job tidies up after itself; a failed or withheld
3551
- // one stays in the sidebar where the person can see it. The pair
3552
- // conversation is the standing line between two bots and never
3553
- // auto-closes.
4191
+ // A successful direct work thread closes after its result is reported:
4192
+ // folded out of the sidebar, never deleted, and reopened if addressed.
4193
+ // Failed or withheld work stays visible.
3554
4194
  const childTask = store.taskByThread(child.botId, child.threadId);
3555
4195
  if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
3556
4196
  && childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
@@ -4199,8 +4839,35 @@ function audienceChanged() {
4199
4839
  }
4200
4840
  }
4201
4841
  }
4842
+ // Cloud boot can revoke sessions before routes are registered. Create the
4843
+ // viewer manager before installing any revocation callbacks.
4844
+ const desktopViewer = createDesktopViewer({
4845
+ target: (id) => {
4846
+ if (id.startsWith("vps/")) {
4847
+ const botId = id.slice(4);
4848
+ if (store.bot(botId)?.cloudBackend !== "vps")
4849
+ return;
4850
+ return { key: id, resolve: async () => {
4851
+ const connection = vps.vpsDesktopConnection(botId);
4852
+ if (!connection)
4853
+ throw Object.assign(new Error("VPS viewer is not open"), { status: 409 });
4854
+ return connection;
4855
+ } };
4856
+ }
4857
+ const targets = [SHARED_LOCAL_VM_TARGET, ...store.bots.map(bot => perBotLocalVmTarget(bot.id)),
4858
+ ...Array.from({ length: localVmMaxInstances(cfg) }, (_, seat) => poolLocalVmTarget(seat))];
4859
+ const target = targets.find(target => viewerTargetId(target) === id);
4860
+ return target && localDesktopTarget(target, {
4861
+ // A viewer can repair a sick CUA driver; skip desktop health probes.
4862
+ status: target => containerComputerStatus(undefined, undefined, target, { probeDesktop: false }),
4863
+ touch: target => localVmIdleFor(target).touch(),
4864
+ });
4865
+ },
4866
+ live: auth => auth.kind === "loopback" || sessions.isLive(auth.session.id),
4867
+ });
4202
4868
  function closeSessionStreams(sessionId) {
4203
4869
  browserLive.closeForOwner(sessionId);
4870
+ desktopViewer.closeForOwner(sessionId);
4204
4871
  for (const client of sseClients) {
4205
4872
  if (client.sessionId !== sessionId)
4206
4873
  continue;
@@ -4215,8 +4882,35 @@ function closeSessionStreams(sessionId) {
4215
4882
  }
4216
4883
  sessions.onSessionRevoked((sessionId) => {
4217
4884
  providerAuthSessions.revokeOwner(sessionId);
4885
+ mcpOAuth.revokeOwner(sessionId);
4218
4886
  closeSessionStreams(sessionId);
4887
+ // A revoked owner device no longer vouches for its lines (cloudOwnerPerson).
4888
+ ownerOnlyCache.clear();
4219
4889
  });
4890
+ /** On a Cloud home: a line someone else adds to a conversation while one of
4891
+ * the owner's turns runs in it (steered into it, or handed in) makes that
4892
+ * turn someone else's from then on. The lent Mac is refused at its next use
4893
+ * anyway (cloudLendingRefusal reads the whole conversation each time); this
4894
+ * makes whatever the bot writes to its memory from now on count as theirs
4895
+ * too (server/lending-memory.ts). */
4896
+ function noteSomeoneElsesLine(threadId) {
4897
+ const generation = activeInternalGenerationByThread.get(threadId);
4898
+ if (!generation || foreignTurns.has(generation))
4899
+ return;
4900
+ const bot = store.botByThread(threadId);
4901
+ if (bot && cloudHomeLendingRefusal(cloudLendingTurn({ botId: bot.id, threadId, generation })) !== null) {
4902
+ noteForeignTurn(bot.id, threadId, generation);
4903
+ }
4904
+ }
4905
+ if (lendingMemory) {
4906
+ store.onChange((change) => {
4907
+ if (change.type === "message" && change.message.role === "user")
4908
+ noteSomeoneElsesLine(change.threadId);
4909
+ // A deleted bot's record goes with it (and nothing recreates its workspace).
4910
+ else if (change.type === "bot.deleted")
4911
+ lendingMemory.forget(change.botId);
4912
+ });
4913
+ }
4220
4914
  /** Every frame is numbered, and the last few hundred are kept, so a client
4221
4915
  * whose connection dropped can ask for what it missed instead of
4222
4916
  * re-downloading every transcript. The desktop reconnects in milliseconds
@@ -4465,6 +5159,10 @@ function notify(notification) {
4465
5159
  // Group threads: the fold needs to know WHO is talking — the turn engine
4466
5160
  // records the active member here before dispatching its turn.
4467
5161
  const groupSpeakers = new Map();
5162
+ /** Auto-room rounds the decision model routed, by thread: the named
5163
+ * speaker's next reply line carries `routedBy` (stamped in the runtime fold
5164
+ * below), so the room can say "Picked by Jev". Only a routed round sets it. */
5165
+ const routedRoomReplies = new Map();
4468
5166
  // The latest running token totals for the turn in flight on each thread.
4469
5167
  // Providers report cumulative-within-turn numbers; the final value is folded
4470
5168
  // into the task's tally when the turn settles.
@@ -4685,10 +5383,34 @@ const memoryUpkeep = createMemoryUpkeep({
4685
5383
  return facts.length;
4686
5384
  },
4687
5385
  sourceLabel: (botId, threadId) => memorySourceLabel({ task: store.taskByThread(botId, threadId), threadId }),
5386
+ // On a Cloud home upkeep only works on the owner's conversations (see the
5387
+ // capture hook below), so its writes are the owner's (lending-memory.ts).
5388
+ ...(lendingMemory ? { writing: (botId, write) => lendingMemory.trustedWrite(botId, write) } : {}),
4688
5389
  quietMs: () => captureQuietMs(cfg),
4689
5390
  tidyHour: () => tidyHour(cfg),
4690
5391
  log: (line) => console.log(line),
4691
5392
  });
5393
+ // OMB Cloud home: the first bot turn that finishes here ticks the setup
5394
+ // checklist's "try something" step (firstCloudTurnPatch). The server writes
5395
+ // it, not an admin, so it is kept out of any admin's recorded changes.
5396
+ bus.subscribe((event) => {
5397
+ if (!CLOUD_HOME || event.type !== "turn.completed" || shouldIgnoreProviderEvent(event))
5398
+ return;
5399
+ const patch = firstCloudTurnPatch({
5400
+ cloudHome: true, recorded: cfg.onboarding?.firstTurnAt, ok: event.ok,
5401
+ known: Boolean(store.botByThread(event.threadId) ?? store.groupByThread(event.threadId)),
5402
+ });
5403
+ if (!patch)
5404
+ return;
5405
+ try {
5406
+ adminActorScope.exit(() => saveConfig(patch));
5407
+ Object.assign(cfg, loadConfig());
5408
+ broadcast({ kind: "config", ...configStatus() });
5409
+ }
5410
+ catch (error) {
5411
+ console.warn(`cloud home: could not record the first finished turn (${error instanceof Error ? error.message : "unknown"})`);
5412
+ }
5413
+ });
4692
5414
  // A finished 1:1 turn of an upkeep bot waits for capture. Only the person's
4693
5415
  // own conversation: not a room, not a turn another bot or the harness
4694
5416
  // started (its "user" line is not the person), not a failed turn.
@@ -4702,6 +5424,10 @@ bus.subscribe((event) => {
4702
5424
  if (!bot || !upkeepEnabled(bot) || store.groupByThread(event.threadId) || isInternalTurn(event.threadId)
4703
5425
  || isUnattended(bot.id, event.threadId) || routines?.runForThread(event.threadId))
4704
5426
  return;
5427
+ // On a Cloud home the bot's memory reaches its owner's turns (and a lent
5428
+ // Mac): nothing is captured from a conversation anyone else wrote in.
5429
+ if (CLOUD_HOME && !cloudOwnerOnlyThread(event.threadId))
5430
+ return;
4705
5431
  const path = store.activePath(event.threadId);
4706
5432
  const replyAt = path.findLastIndex((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
4707
5433
  if (replyAt < 0)
@@ -4734,7 +5460,7 @@ bus.subscribe((event) => {
4734
5460
  const messages = store.messagesFor(event.threadId);
4735
5461
  const reply = messages.findLast((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
4736
5462
  const bot = store.botByThread(event.threadId) ?? (reply?.from ? store.bot(reply.from.botId) : undefined);
4737
- if (!bot)
5463
+ if (!bot || bot.memoryEnabled === false)
4738
5464
  return;
4739
5465
  const tools = messages
4740
5466
  .filter((message) => message.kind === "activity" && message.turnId === event.turnId && message.tool?.name)
@@ -4747,13 +5473,21 @@ bus.subscribe((event) => {
4747
5473
  const room = store.groupByThread(event.threadId);
4748
5474
  if (room && !roomFeedsBot(room, bot))
4749
5475
  return;
4750
- appendMemoryLog(bot.id, line, {
5476
+ // On a Cloud home the log feeds recall in the owner's turns: only the
5477
+ // owner's own conversations leave a line.
5478
+ if (CLOUD_HOME && (room || !cloudOwnerOnlyThread(event.threadId)))
5479
+ return;
5480
+ const writeLine = () => appendMemoryLog(bot.id, line, {
4751
5481
  source: memorySourceLabel({
4752
5482
  room,
4753
5483
  task: store.taskByThread(bot.id, event.threadId),
4754
5484
  threadId: event.threadId,
4755
5485
  }),
4756
5486
  });
5487
+ if (lendingMemory)
5488
+ lendingMemory.trustedWrite(bot.id, writeLine);
5489
+ else
5490
+ writeLine();
4757
5491
  }
4758
5492
  catch {
4759
5493
  // a missing note never fails a turn
@@ -5038,13 +5772,18 @@ const boatLifecycleBusyBots = new Set();
5038
5772
  // A refresh is a reader, not a lifecycle change. Keep its reservation until
5039
5773
  // the provider settles even if the HTTP client leaves, and share it on retry.
5040
5774
  const vpsPreviewRequests = new Map();
5775
+ // Joins in flight per bot. A second tab or a quick reconnect waits for the
5776
+ // pending join, then reuses its tunnel, instead of being refused by its lane.
5777
+ const vpsJoinRequests = new Map();
5041
5778
  const orphanBoatLifecycleBusyIds = new Set();
5042
5779
  const boatInventoryRequestsBusyIds = new Set();
5043
5780
  const computerProviderConfigTransitions = new Set();
5044
5781
  // A restore mutates and cleans a project work tree. Claim the bot across the
5045
5782
  // entire async Git operation so a turn cannot start in that folder midway.
5046
5783
  const checkpointRestoreLeases = new Set();
5047
- const LOCAL_VM_IDLE_MS = 8 * 60 * 60_000;
5784
+ /** The effective idle window, read from config so a Settings change applies
5785
+ * to both new and already-armed timers (see the config route). */
5786
+ const localVmIdleMs = () => localVmIdleTimeoutMinutes(cfg) * 60_000;
5048
5787
  /** How long a turn waits for Cua Driver after starting the container itself.
5049
5788
  * A cold XFCE desktop needs some seconds; past this the turn reports the
5050
5789
  * status it has rather than hanging on a container that will not come up. */
@@ -5175,7 +5914,7 @@ async function teamComputersPayload() {
5175
5914
  * Assignment authorizes waking, never replacing a missing paid machine. */
5176
5915
  async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
5177
5916
  if (!canMount)
5178
- throw new Error("This model engine cannot use the team's Boat computer; choose an engine with computer tools or an explicit bot destination");
5917
+ throw new Error("This model cannot use the team's Boat computer; choose a model provider with computer tools or an explicit bot destination");
5179
5918
  if (!boat.boatConfigured(cfg))
5180
5919
  throw new Error("The team's Boat account is not configured; reconnect it in Settings");
5181
5920
  const ownerId = teamComputerOwner(computer.id);
@@ -5199,7 +5938,7 @@ async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
5199
5938
  !turnResources.owns(`computer:box:${machine.id}`, owner))
5200
5939
  throw new Error("This computer turn ended while its machine was starting");
5201
5940
  return {
5202
- integration: { kind: "box", boxId: machine.id, token: cfg.box.token, control: controlIntegration(botId, owner.threadId, owner.generation) },
5941
+ integration: { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(botId, owner.threadId, owner.generation) },
5203
5942
  capture: () => boat.screenshotBoat(cfg, ownerId, machine.id),
5204
5943
  };
5205
5944
  }
@@ -5211,7 +5950,7 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
5211
5950
  // ACP engine" while already on one has nowhere to go.
5212
5951
  throw new Error(providerSupportsLocal
5213
5952
  ? `local computer control is not available on ${process.platform} — select another destination`
5214
- : "this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination");
5953
+ : "this model cannot control this computer — choose Claude or an ACP model provider, or select another destination");
5215
5954
  }
5216
5955
  const cua = readCuaConnection();
5217
5956
  if (!cua) {
@@ -5305,7 +6044,7 @@ async function attachBotBoat(bot, owner, opts) {
5305
6044
  return {
5306
6045
  capture: () => boat.screenshotBoat(cfg, bot.id, machine.id),
5307
6046
  integration: opts.canMount
5308
- ? { kind: "box", boxId: machine.id, token: cfg.box.token, control: controlIntegration(bot.id, owner.threadId, owner.generation) }
6047
+ ? { kind: "box", boxId: machine.id, token: boat.boatAccount(cfg)?.token ?? "", control: controlIntegration(bot.id, owner.threadId, owner.generation) }
5309
6048
  : null,
5310
6049
  };
5311
6050
  }
@@ -5418,12 +6157,13 @@ async function computerPreviewSurface(bot, threadId) {
5418
6157
  return "cloud";
5419
6158
  }
5420
6159
  const target = localVmTargetForStatus(bot.id, threadId);
5421
- if (instance?.adapter.capabilities.computerMcp && localVmSeen.has(target.key)) {
6160
+ // A Cloud home has neither place (cloud-home.ts), so Auto never shows one.
6161
+ if (!CLOUD_HOME && instance?.adapter.capabilities.computerMcp && localVmSeen.has(target.key)) {
5422
6162
  const vm = await containerComputerStatus(undefined, undefined, target).catch(() => null);
5423
6163
  if (vm && autoLocalVmAttachable(vm))
5424
6164
  return "vm";
5425
6165
  }
5426
- if (shouldMountLocalComputer({ requested: undefined, hostPlatform: process.platform,
6166
+ if (!CLOUD_HOME && shouldMountLocalComputer({ requested: undefined, hostPlatform: process.platform,
5427
6167
  providerSupportsLocal: instance?.adapter.capabilities.localComputerMcp === true }) && readCuaConnection())
5428
6168
  return "local";
5429
6169
  if (bot.cloudBackend === "vps")
@@ -5431,16 +6171,23 @@ async function computerPreviewSurface(bot, threadId) {
5431
6171
  return plan.browser ? "browser" : "off";
5432
6172
  }
5433
6173
  /** Discovery is read-only. Starting or creating a configured computer is
5434
- * deferred until a chat tool selects it and the old turn releases its tools. */
6174
+ * deferred until a chat tool selects it and the old turn releases its tools.
6175
+ * A Cloud home lists only the places it has (cloud-home.ts), so a bot never
6176
+ * tells the person to set up this computer or a Local VM there. */
5435
6177
  async function selectableComputers(bot) {
5436
6178
  const caps = registry.get(bot.modelSelection.instanceId)?.adapter.capabilities;
5437
6179
  const off = bot.computer === "off";
5438
6180
  const localEngine = caps?.remoteAgent !== true;
5439
- return Promise.all(["cloud", "vm", "local", "browser"].map(async (surface) => {
6181
+ const surfaces = ["cloud", "vm", "local", "browser"].filter(surface => !CLOUD_HOME || cloudHomeOffersPlace(surface));
6182
+ return Promise.all(surfaces.map(async (surface) => {
5440
6183
  let ready = false;
5441
6184
  let canStart = false;
5442
6185
  let canCreate = false;
5443
- let reason = "This computer is not configured or running. Open the Computer panel to set it up.";
6186
+ // On a Cloud home "this computer" reads as the person's own; name the
6187
+ // place the reason is about.
6188
+ let reason = CLOUD_HOME && surface === "cloud"
6189
+ ? "The cloud computer is not set up or running. Open the Computer panel to set it up."
6190
+ : "This computer is not configured or running. Open the Computer panel to set it up.";
5444
6191
  try {
5445
6192
  if (off)
5446
6193
  reason = "Computer access is Off in this bot's settings.";
@@ -5476,7 +6223,7 @@ async function selectableComputers(bot) {
5476
6223
  }
5477
6224
  else if (surface === "browser") {
5478
6225
  ready = caps?.browserMcp === true && builtInBrowserEnabled(cfg) && bot.browser !== false && browserEngineStatus().kind === "ready";
5479
- reason = "The built-in browser is disabled, not installed, or unsupported by this model engine.";
6226
+ reason = "The built-in browser is disabled, not installed, or unsupported by this model.";
5480
6227
  }
5481
6228
  }
5482
6229
  catch (error) {
@@ -5627,7 +6374,7 @@ function localVmIdleFor(target) {
5627
6374
  let idle = localVmIdles.get(target.key);
5628
6375
  if (idle)
5629
6376
  return idle;
5630
- idle = new LocalVmIdleTimer(LOCAL_VM_IDLE_MS, () => localVmImageBusy || localVmLifecycleBusy.has(target.key) || localVmActiveThreads.has(target.key), async () => {
6377
+ idle = new LocalVmIdleTimer(localVmIdleMs(), () => localVmImageBusy || localVmLifecycleBusy.has(target.key) || localVmActiveThreads.has(target.key), async () => {
5631
6378
  localVmLifecycleBusy.add(target.key);
5632
6379
  try {
5633
6380
  const status = await containerComputerStatus(undefined, undefined, target);
@@ -5826,7 +6573,12 @@ bus.subscribe((event) => {
5826
6573
  const owner = bot ? directRequestOwners.get(event.threadId) : undefined;
5827
6574
  const proven = owner?.messageId && !owner.stopped && owner.generation === directTurnGenerationByThread.get(event.threadId) &&
5828
6575
  owner.turnId && (!event.turnId || event.turnId === owner.turnId);
5829
- const message = store.appendMessage(event.threadId, group && m.role === "bot" ? { ...m, from: speaker }
6576
+ // One line per routed reply: the routed speaker's first text of its turn.
6577
+ const routed = group && m.role === "bot" && m.kind === "text" ? routedRoomReplies.get(event.threadId) : undefined;
6578
+ const routedBy = routed && speaker && routed.botId === speaker.botId ? routed.routedBy : undefined;
6579
+ if (routedBy)
6580
+ routedRoomReplies.delete(event.threadId);
6581
+ const message = store.appendMessage(event.threadId, group && m.role === "bot" ? { ...m, from: speaker, ...(routedBy ? { routedBy } : {}) }
5830
6582
  : proven ? { ...m, requestMessageId: owner.messageId } : m);
5831
6583
  return message;
5832
6584
  };
@@ -5963,10 +6715,13 @@ bus.subscribe((event) => {
5963
6715
  // always reaches the human — even Full access never invents an answer.
5964
6716
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
5965
6717
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
5966
- const effectiveApprovalMode = asker ? approvalModeForTurn(asker, isInternalTurn(event.threadId)) : "ask";
6718
+ // A turn a guest drives on a Cloud home is Ask, room turns included,
6719
+ // and no command the owner saved answers for it.
6720
+ const guestDriven = cloudGuestDriven(event.threadId);
6721
+ const effectiveApprovalMode = asker && !guestDriven ? approvalModeForTurn(asker, isInternalTurn(event.threadId), event.threadId) : "ask";
5967
6722
  // Native shell descriptors are distinct from computer/MCP permissions;
5968
6723
  // choosing a local desktop must not disable an exact shell grant.
5969
- const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command
6724
+ const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command && !guestDriven
5970
6725
  ? commandAllowlistCandidate({ ...event.command, providerInstanceId: event.providerInstanceId ?? asker.modelSelection.instanceId })
5971
6726
  : null;
5972
6727
  const verdict = permission && asker && event.requestId
@@ -6071,7 +6826,8 @@ bus.subscribe((event) => {
6071
6826
  : undefined,
6072
6827
  commandAllowlist: command ?? undefined,
6073
6828
  // Provider-owned session grants remain separate from exact commands.
6074
- allowSession: permission && event.allowSession && !event.requiresExplicitApproval ? true : undefined,
6829
+ // Never on a guest's turn: an "always" for it would outlive it.
6830
+ allowSession: permission && event.allowSession && !event.requiresExplicitApproval && !guestDriven ? true : undefined,
6075
6831
  // The text stays for cards saved before heldCode existed, and for
6076
6832
  // clients that do not know the key yet.
6077
6833
  held: approvalHeldReason(heldContext),
@@ -6149,12 +6905,19 @@ bus.subscribe((event) => {
6149
6905
  tool: { name: `retrying — attempt ${event.attempt + 1}/${RETRY_MAX_ATTEMPTS} in ${Math.round(event.delayMs / 1000)}s — ${event.reason}`, ok: true },
6150
6906
  });
6151
6907
  break;
6908
+ case "runtime.notice":
6909
+ // informational: the turn carries on. The prefix keeps it a status row
6910
+ // the person always sees (src/lib/activity-runs.ts), never a tool step
6911
+ // hidden with Tool calls off.
6912
+ pushMessage({ role: "bot", kind: "activity", tool: { name: `notice: ${event.message.slice(0, 240)}`, ok: true } });
6913
+ break;
6152
6914
  case "runtime.error":
6153
6915
  pushMessage({
6154
6916
  role: "bot",
6155
6917
  kind: "activity",
6156
6918
  tool: {
6157
- name: `error: ${event.message.slice(0, 160)}`,
6919
+ // a setup error carries its fix, which must not be cut mid-command
6920
+ name: `error: ${event.message.slice(0, event.setup ? 320 : 160)}`,
6158
6921
  ok: false,
6159
6922
  setup: event.setup,
6160
6923
  ...(event.terminal ? { terminal: true } : {}),
@@ -7399,14 +8162,10 @@ async function finalScreenFrame(_botId, threadId) {
7399
8162
  * (generateText — Haiku on Claude, the chat completion endpoint's text
7400
8163
  * path on OpenAI-compatible engines). Null whenever that call cannot run,
7401
8164
  * runs long, or answers with something that is not a plain short title;
7402
- * the caller keeps the snippet it already applied. */
7403
- async function generateThreadTitle(provider, text) {
7404
- const prompt = [
7405
- "Name the conversation that begins with the message below.",
7406
- "Reply with only a short title: 3 to 6 words, plain text, no quotes, no trailing period.",
7407
- "Message:",
7408
- text.trim().slice(0, 1_500),
7409
- ].join("\n");
8165
+ * the caller keeps the snippet it already applied. "conversation" names an
8166
+ * existing thread from titleConversationExcerpt (Regenerate title). */
8167
+ async function generateThreadTitle(provider, text, source = "first-message") {
8168
+ const prompt = threadTitlePrompt(text, source);
7410
8169
  const expiry = new AbortController();
7411
8170
  let timer;
7412
8171
  try {
@@ -7430,6 +8189,8 @@ async function generateThreadTitle(provider, text) {
7430
8189
  clearTimeout(timer);
7431
8190
  }
7432
8191
  }
8192
+ /** Threads whose Regenerate title one-shot is still running: one at a time. */
8193
+ const titleRegenerations = new Set();
7433
8194
  // ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
7434
8195
  async function startTurn(botId, text, opts) {
7435
8196
  workspaceMaintenance.assertAvailable();
@@ -7516,6 +8277,15 @@ async function startTurn(botId, text, opts) {
7516
8277
  const policyRefusal = policyModelRefusal(instance);
7517
8278
  if (policyRefusal)
7518
8279
  throw Object.assign(new Error(policyRefusal), { status: 409, code: "managed_policy" });
8280
+ // On a Cloud home a guest's turn never gets a shell or reads outside its
8281
+ // own folder (docs/cloud-pro.md): an engine that cannot run it that way is
8282
+ // refused, in one plain line, before anything is recorded.
8283
+ // (The thread is idle here: a mark a turn that never started left is stale.)
8284
+ guestDrivenTurns.delete(threadId);
8285
+ const guestConfined = cloudGuestDriven(threadId) || cloudGuestDrivenHandoff(opts?.coordination?.id);
8286
+ if (guestConfined && instance.adapter.capabilities.guestTurns !== "confined") {
8287
+ throw Object.assign(new Error(guestEngineRefusal(threadId)), { status: 409, code: "guest_engine" });
8288
+ }
7519
8289
  // Resolve only transport tags from this newly submitted text. The original
7520
8290
  // string remains the durable message. Native-image providers get a
7521
8291
  // path-free prompt and bounded inputs instead of needing a Read tool;
@@ -7536,11 +8306,9 @@ async function startTurn(botId, text, opts) {
7536
8306
  const titled = store.titleTaskFromFirstMessage(bot.id, resolvedImages.text, threadId);
7537
8307
  // The snippet is only the fallback name. A cheap one-shot may trade it
7538
8308
  // for a title a person would have typed, but never on a peer-opened
7539
- // row: adoption recognises those by the exact title their assignment
7540
- // gave them (openingRequestTitle), and a generated one would break the
7541
- // comparison it renames under. Everywhere else, the swap happens only
7542
- // while the row still carries the snippet — a rename by the person or
7543
- // by adoption has already broken that equality by then.
8309
+ // row: its title belongs to the assigned work. Everywhere else, the
8310
+ // swap happens only while the row still carries the snippet — a rename
8311
+ // by a person has already broken that equality by then.
7544
8312
  const snippet = titled?.title;
7545
8313
  if (titled && snippet && !titled.openedBy?.botId && llmThreadTitlesEnabled(cfg) && instance.generateText) {
7546
8314
  void generateThreadTitle(instance, resolvedImages.text)
@@ -7572,7 +8340,7 @@ async function startTurn(botId, text, opts) {
7572
8340
  // an edit hands us its already-branched user message; a plain send appends
7573
8341
  let userMessage = opts?.userMessage;
7574
8342
  if (opts?.editedMessageId) {
7575
- const edited = store.branchMessage(threadId, opts.editedMessageId, text, opts.sendId);
8343
+ const edited = store.branchMessage(threadId, opts.editedMessageId, text, opts.sendId, opts.sender);
7576
8344
  if (!edited)
7577
8345
  throw Object.assign(new Error("only a user text message can be edited"), { status: 400 });
7578
8346
  store.patchTask(bot.id, threadId, { rewound: true });
@@ -7617,6 +8385,9 @@ async function startTurn(botId, text, opts) {
7617
8385
  // in the background — boat provisioning can take ~90s and must never
7618
8386
  // hang the HTTP request
7619
8387
  const dispatchClaimId = randomUUID();
8388
+ // Guest-driven for as long as it runs, wherever it runs (cloudGuestDriven).
8389
+ if (guestConfined)
8390
+ guestDrivenTurns.set(threadId, dispatchClaimId);
7620
8391
  const resourceOwner = { threadId, generation: dispatchClaimId };
7621
8392
  turnResourceOwners.set(threadId, resourceOwner);
7622
8393
  directTurnGenerationByThread.set(threadId, dispatchClaimId);
@@ -7630,7 +8401,11 @@ async function startTurn(botId, text, opts) {
7630
8401
  }
7631
8402
  requestGenerations.add(dispatchClaimId);
7632
8403
  directRequestOwners.set(threadId, { generation: dispatchClaimId, messageId: requestMessageId,
7633
- generations: requestGenerations, turnId: null });
8404
+ generations: requestGenerations, turnId: null, ...(opts?.automationSource ? { automation: opts.automationSource } : {}) });
8405
+ // On a Cloud home, a turn that is not provably the owner's leaves this
8406
+ // bot's memory "pending" until it ends (server/lending-memory.ts).
8407
+ if (CLOUD_HOME && cloudHomeLendingRefusal(cloudLendingTurn({ botId, threadId, generation: dispatchClaimId })) !== null)
8408
+ noteForeignTurn(botId, threadId, dispatchClaimId);
7634
8409
  // An unknown control-plane wake may invalidate a prior final, but it must
7635
8410
  // never acquire authority by guessing the latest user as its origin.
7636
8411
  const pendingSource = requestMessageId ?? store.activePath(threadId).findLast(message => message.role === "user")?.id;
@@ -7756,9 +8531,9 @@ async function startTurn(botId, text, opts) {
7756
8531
  const externalUpdate = Boolean(opts?.coordination?.resumed || unseen?.some((m) => m.keep));
7757
8532
  // What a resumed session keeps from its launch: the standing instructions
7758
8533
  // (tools, servers and — for Claude — the model are passed on every launch),
7759
- // plus whatever this engine can only set when a session starts. Codex's
7760
- // thread/resume sends no model selection, and an effort it is not sent stays
7761
- // at the thread's last value, so both belong to the session there. An
8534
+ // plus settings that a live turn cannot change. Codex now reasserts its
8535
+ // model/provider on resume, but an in-flight turn and an omitted effort
8536
+ // retain the prior selection, so both still belong to the session here. An
7762
8537
  // external update that finds any of it changed since the session started
7763
8538
  // gets the fresh session and replay it always got, rather than a resume.
7764
8539
  const persistentConfig = [bot.name, bot.title, bot.description, sectionContextSystemPrompt(bot.section),
@@ -7859,7 +8634,7 @@ async function startTurn(botId, text, opts) {
7859
8634
  // composio — only to a driver that can mount them. Their tools are
7860
8635
  // never pre-allowed, so every call rides the normal permission flow.
7861
8636
  if (instance.adapter.capabilities.customMcp === true) {
7862
- const custom = engineMcpServers(bot);
8637
+ const custom = await withMcpSignIn(engineMcpServers(bot), mcpOAuth);
7863
8638
  if (Object.keys(custom).length)
7864
8639
  integrations.custom = custom;
7865
8640
  }
@@ -7871,7 +8646,8 @@ async function startTurn(botId, text, opts) {
7871
8646
  if (worksInWorkspace) {
7872
8647
  ensureWorkspace(bot.id);
7873
8648
  // baseline for the journal's turn-boundary diff (see the bus hook)
7874
- beginMemoryTurn(bot.id, threadId);
8649
+ if (bot.memoryEnabled !== false)
8650
+ beginMemoryTurn(bot.id, threadId);
7875
8651
  }
7876
8652
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
7877
8653
  const skillInstructions = renderSkillInstructions(selectedSkills, {
@@ -7887,8 +8663,13 @@ async function startTurn(botId, text, opts) {
7887
8663
  // bot's folder for a task that runs elsewhere.
7888
8664
  if (opts?.runOn === "cloud")
7889
8665
  store.pinTaskCwd(bot.id, threadId, undefined, { none: true });
8666
+ // On a Cloud home a conversation a guest opened is pinned to its own
8667
+ // folder when it first runs, not the bot's project folder the owner's
8668
+ // conversations share (what it leaves there reaches none of them). One
8669
+ // that ran before keeps its pin, unless the session that opened it was
8670
+ // revoked: then it was unpinned at boot (server/cloud-owner.ts).
7890
8671
  const pinnedCwd = privateWorkspace && opts?.runOn !== "cloud"
7891
- ? store.pinTaskCwd(bot.id, threadId, privateWorkspace)
8672
+ ? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
7892
8673
  : null;
7893
8674
  const cwd = pinnedCwd ?? undefined;
7894
8675
  if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
@@ -7926,13 +8707,13 @@ async function startTurn(botId, text, opts) {
7926
8707
  throw new Error("the Computer engine works on the cloud computer — set Works on to Cloud, or choose another engine");
7927
8708
  }
7928
8709
  const wants = plan.computer;
7929
- // A place the organisation disallows is refused before anything is
7930
- // prepared; Auto below simply skips disallowed places.
8710
+ // A place the organisation disallows, or a Cloud home never offers, is
8711
+ // refused before anything is prepared; Auto below simply skips them.
7931
8712
  const wantedKind = teamComputer ? "box" : wants === "local" ? "thisComputer" : wants === "vm" ? "localVm"
7932
8713
  : wants === "cloud" ? (cloudBackend === "vps" ? "vps" : "box") : undefined;
7933
- const placeRefusal = wantedKind && managedPolicy.computerRefusal(wantedKind);
8714
+ const placeRefusal = wantedKind && computerPlaceRefusal(wantedKind);
7934
8715
  if (placeRefusal)
7935
- throw Object.assign(new Error(placeRefusal), { status: 409, code: "managed_policy" });
8716
+ throw Object.assign(new Error(placeRefusal.message), { status: 409, code: placeRefusal.code });
7936
8717
  let previewCapture = null;
7937
8718
  let browserCapture = null;
7938
8719
  let computerKind = null;
@@ -8084,7 +8865,7 @@ async function startTurn(botId, text, opts) {
8084
8865
  if (!mountsComputerMcp || instance.adapter.capabilities.remoteAgent === true) {
8085
8866
  if (!strict)
8086
8867
  return false;
8087
- throw new Error("this model engine cannot use the Local VM — choose Claude or an ACP engine, or select another computer destination");
8868
+ throw new Error("this model cannot use the Local VM — choose Claude or an ACP model provider, or select another computer destination");
8088
8869
  }
8089
8870
  if (!strict && localVmMode(cfg) === "pool") {
8090
8871
  // localVmTargetForThread records pool affinity, so a turn the
@@ -8243,7 +9024,7 @@ async function startTurn(botId, text, opts) {
8243
9024
  }
8244
9025
  }
8245
9026
  if (wants === "cloud" && cloudBackend === "box" && !boat.boatConfigured(cfg)) {
8246
- throw new Error("Cloud Boat is not configured — add a Boat API key or choose Local VM");
9027
+ throw new Error(BOAT_NOT_CONFIGURED);
8247
9028
  }
8248
9029
  if (wants === "cloud" && cloudBackend === "box" && !integrations.computer) {
8249
9030
  throw new Error("the cloud computer could not be created or reached");
@@ -8253,7 +9034,7 @@ async function startTurn(botId, text, opts) {
8253
9034
  // Auto reaches a Local VM this bot already has before it ever touches the
8254
9035
  // host's own desktop: on a headless server that VM is the only desktop
8255
9036
  // there is, and a person who prepared one meant it to be used.
8256
- if (wants === undefined && !integrations.computer && !integrations.localComputer && managedPolicy.computerAllowed("localVm") && await attachLocalVm(false))
9037
+ if (wants === undefined && !integrations.computer && !integrations.localComputer && !computerPlaceRefusal("localVm") && await attachLocalVm(false))
8257
9038
  computerKind = "vm";
8258
9039
  // An unattended run on a bot with a VPS configured never lands on the
8259
9040
  // host's own desktop instead: a scheduled job clicking on someone's
@@ -8263,7 +9044,7 @@ async function startTurn(botId, text, opts) {
8263
9044
  !integrations.localComputer &&
8264
9045
  wants === undefined &&
8265
9046
  !unattendedVps &&
8266
- managedPolicy.computerAllowed("thisComputer") &&
9047
+ !computerPlaceRefusal("thisComputer") &&
8267
9048
  shouldMountLocalComputer({
8268
9049
  requested: undefined,
8269
9050
  hostPlatform: process.platform,
@@ -8300,9 +9081,9 @@ async function startTurn(botId, text, opts) {
8300
9081
  autoVpsProblem &&
8301
9082
  !computerSelectionTurns.has(threadId)) {
8302
9083
  const hint = opts?.automationSource
8303
- ? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in App Settings → Connections."
9084
+ ? "This scheduled run tried to start the VPS computer and could not reach it. Check the VPS connection in Settings → API keys."
8304
9085
  : bot.autoStartVps
8305
- ? "Check the VPS connection in App Settings → Connections."
9086
+ ? "Check the VPS connection in Settings → API keys."
8306
9087
  : "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
8307
9088
  throw new Error(`${autoVpsProblem}. ${hint}`);
8308
9089
  }
@@ -8348,7 +9129,7 @@ async function startTurn(botId, text, opts) {
8348
9129
  const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT + (boundedCoordination ? "" : THREADS_PROMPT) : "";
8349
9130
  const routinePrompt = integrations.agents ? ROUTINE_PROMPT : "";
8350
9131
  const profilePrompt = integrations.agents ? PROFILE_PROMPT : "";
8351
- const recallPrompt = integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "";
9132
+ const recallPrompt = integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "";
8352
9133
  const learnPrompt = skillAuthoring ? LEARN_PROMPT : "";
8353
9134
  // (activeVpsThreads was already claimed above, before the provision or
8354
9135
  // reuse await, so the backend guards saw this turn the whole time.)
@@ -8462,7 +9243,8 @@ async function startTurn(botId, text, opts) {
8462
9243
  { id: "files", label: "File locations", text: worksInWorkspace && opts?.runOn !== "cloud" ? workspaceLocationsPrompt(bot.id, cwd, liveBot?.cwd ?? bot.cwd) : "" },
8463
9244
  { id: "computer", label: "Computer", text: computerPrompt(computerPromptKind) },
8464
9245
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(teamComputer) },
8465
- { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId) }) },
9246
+ { id: "plan", label: "Surface", text: surfacePrompt({ computer: mountedComputer, browser: Boolean(integrations.browser) }, { pinned: plan.pinned, note: plan.note, canSelect: computerSelectionTurns.has(threadId), cloudHome: Boolean(CLOUD_HOME) }) },
9247
+ { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
8466
9248
  // gated on the integration, not the key: the hint only goes to a
8467
9249
  // bot whose driver actually mounted the tools
8468
9250
  { id: "composio", label: "Connected apps", text: integrations.composio ? composioSystemPrompt(liveBot?.connectorTools ?? bot.connectorTools) : "" },
@@ -8480,8 +9262,8 @@ async function startTurn(botId, text, opts) {
8480
9262
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
8481
9263
  // what the bot said lately in its other conversations, so a task
8482
9264
  // never redoes — or forgets — what another one already did
8483
- { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
8484
- { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace }) },
9265
+ { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId, ...recentWorkFilter() })) },
9266
+ { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false }) },
8485
9267
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
8486
9268
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
8487
9269
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
@@ -8525,7 +9307,8 @@ async function startTurn(botId, text, opts) {
8525
9307
  text: withRecalled(recalled, dispatchContext.turnText),
8526
9308
  refreshSystemPrompt: true,
8527
9309
  images: turnImages,
8528
- approvalMode: approvalModeForTurn(bot, commsDepth > 0),
9310
+ approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
9311
+ ...(guestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
8529
9312
  model,
8530
9313
  effort,
8531
9314
  variant,
@@ -8816,7 +9599,10 @@ function routineRunFallbackText(card) {
8816
9599
  : card.status === "queued" && card.deferredAt != null
8817
9600
  ? "deferred: target busy"
8818
9601
  : card.status);
8819
- return `Routine “${card.routineName}” ${state}`;
9602
+ // Clients without the card (older phones) draw only this line, so it
9603
+ // carries what the run said, not just that it finished.
9604
+ const detail = card.summary ?? card.error;
9605
+ return `Routine “${card.routineName}” ${state}${detail ? `\n\n${detail}` : ""}`;
8820
9606
  }
8821
9607
  /** Upsert one durable lifecycle card per run. Replaying the same transition,
8822
9608
  * including restart recovery, patches the existing run id instead of adding
@@ -8861,9 +9647,14 @@ function syncRoutineRunToSource(run) {
8861
9647
  const execution = store.taskByThread(run.botId, run.threadId);
8862
9648
  const freshExecution = execution && run.status === "running" && !execution.routineRunId &&
8863
9649
  store.messagesFor(run.threadId).length === 0;
8864
- if (execution && (freshExecution || (execution.routineRunId === run.id && execution.unread))) {
9650
+ if (execution && freshExecution) {
8865
9651
  store.patchTask(run.botId, run.threadId, { routineRunId: run.id, unread: false });
8866
9652
  }
9653
+ else if (execution?.routineRunId === run.id && execution.unread) {
9654
+ // Already this run. Clear the dot without rewriting the tag, so a
9655
+ // promoted thread (tag cleared, transcript present) stays visible.
9656
+ store.patchTask(run.botId, run.threadId, { unread: false });
9657
+ }
8867
9658
  }
8868
9659
  // Merely queueing/running is ambient progress. Attention and terminal
8869
9660
  // states become unread in the conversation where the user asked for them.
@@ -8955,8 +9746,13 @@ routines = new RoutineManager({
8955
9746
  }
8956
9747
  return groupIsWorking(group) || coordinator.busy ? "busy" : "ready";
8957
9748
  },
8958
- createTask: (botId, title, activate = false) => {
9749
+ createTask: (botId, title, activate = false, routineId) => {
8959
9750
  const task = store.createTask(botId, title, activate);
9751
+ // On a Cloud home a run's conversation is opened, like its results
9752
+ // conversation, by whoever wrote the routine: a run of one that is
9753
+ // nobody's is confined to a folder of its own, never the bot's project.
9754
+ if (task && CLOUD_HOME && routineId)
9755
+ threadStarters.set(task.threadId, routineOpener(routineId));
8960
9756
  const bot = store.bot(botId);
8961
9757
  if (task && bot)
8962
9758
  broadcast({ kind: "bot", bot: publicBot(bot) });
@@ -8984,7 +9780,12 @@ routines = new RoutineManager({
8984
9780
  // explicit resultsThreadId must be a visible task owned by the running bot.
8985
9781
  if (!forceNew && routineSourceOwner(routine))
8986
9782
  return routine.resultsThreadId;
8987
- return store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
9783
+ const threadId = store.createTask(routine.botId, `${routine.name} · Results`, false)?.threadId;
9784
+ // On a Cloud home the results conversation is opened by whoever wrote the
9785
+ // routine: it carries their name for it (cloudGuestOpened).
9786
+ if (threadId && CLOUD_HOME)
9787
+ threadStarters.set(threadId, routineOpener(routine.id));
9788
+ return threadId;
8988
9789
  },
8989
9790
  discardResultsThread: (botId, threadId) => {
8990
9791
  const task = store.taskByThread(botId, threadId);
@@ -9046,6 +9847,63 @@ routines = new RoutineManager({
9046
9847
  notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
9047
9848
  },
9048
9849
  });
9850
+ // A Cloud home is personal (server/cloud-owner.ts): every stored session
9851
+ // without admin scope is revoked and what it opened or wrote is nobody's;
9852
+ // once (and after a restore) the earlier keys are settled. Before anything
9853
+ // is served, and before any routine can run.
9854
+ if (CLOUD_HOME && CLOUD_OWNER_KEY && cloudRoutineAuthors) {
9855
+ const authors = cloudRoutineAuthors;
9856
+ const routineById = (id) => routines?.listRoutines().find((routine) => routine.id === id);
9857
+ cloudEarlierOwners = settleCloudOwnership({
9858
+ file: join(DATA_DIR, "cloud-owner.json"),
9859
+ machineId: CLOUD_HOME.machineId,
9860
+ ownerKey: CLOUD_OWNER_KEY,
9861
+ nobodyKey: CLOUD_NOBODY_KEY,
9862
+ sessions,
9863
+ personKey,
9864
+ starters: threadStarters,
9865
+ writers: authors,
9866
+ routines: {
9867
+ ids: () => (routines?.listRoutines() ?? []).map((routine) => routine.id),
9868
+ writer: (id) => authors.writer(id),
9869
+ fingerprinted: (id) => { const routine = routineById(id); return Boolean(routine) && authors.authored(id, routine); },
9870
+ resultsOpener: (id) => { const results = routineById(id)?.resultsThreadId; return results ? threadStarters.get(results) : undefined; },
9871
+ shape: (id) => { const routine = routineById(id); return routine ? approvalShape(routine) : undefined; },
9872
+ name: (id, person) => authors.wrote(id, person),
9873
+ pause: (ids) => { for (const id of ids)
9874
+ routines?.update(id, { enabled: false }); },
9875
+ },
9876
+ unpin: (threadIds) => { for (const threadId of threadIds)
9877
+ store.unpinTaskCwd(threadId); },
9878
+ lines: () => {
9879
+ const senders = new Set(), answerers = new Set();
9880
+ const approvals = [];
9881
+ const threads = new Set([...store.bots.flatMap((bot) => store.tasks(bot.id).map((task) => task.threadId)), ...store.groups.map((group) => group.threadId)]);
9882
+ for (const threadId of threads) {
9883
+ for (const line of store.messagesFor(threadId)) {
9884
+ const sender = linePersonKey(line);
9885
+ if (sender)
9886
+ senders.add(sender);
9887
+ const answerer = line.card?.answeredBy?.kind === "session" ? line.card.answeredBy.person : undefined;
9888
+ if (answerer)
9889
+ answerers.add(answerer);
9890
+ // A routine a bot proposed, created when this person allowed its card.
9891
+ const request = line.card?.routineRequest;
9892
+ if (answerer && line.card?.answered === "allow" && request?.operation.action === "create" && request.resultId) {
9893
+ const definition = request.operation.routine;
9894
+ approvals.push([request.resultId, answerer, approvalShape({ prompt: definition.instructions, botId: request.operation.forBot?.botId ?? request.botId,
9895
+ runOn: definition.runOn, schedule: asSchedule(definition.schedule, 0) })]);
9896
+ }
9897
+ }
9898
+ }
9899
+ return { senders, answerers, approvals };
9900
+ },
9901
+ ...(restoredAtStart ? { restoredNow: restoredAtStart } : {}),
9902
+ ...(workspaceRestore.restored && workspaceRestore.id ? { lastRestore: workspaceRestore.id } : {}),
9903
+ log: (line) => console.log(line),
9904
+ });
9905
+ ownerOnlyCache.clear();
9906
+ }
9049
9907
  orgLibrary = new OrgLibrary({
9050
9908
  dataDir: DATA_DIR,
9051
9909
  store,
@@ -9120,7 +9978,7 @@ async function cloudRoutineReadiness(botId, threadId) {
9120
9978
  return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
9121
9979
  try {
9122
9980
  if ((await instance.snapshot()).state !== "available") {
9123
- return { ready: false, reason: "The target bot's model engine is not ready to use the Boat cloud computer." };
9981
+ return { ready: false, reason: "The target bot's model is not ready to use the Boat cloud computer." };
9124
9982
  }
9125
9983
  const teamComputer = inheritedTeamComputer(bot);
9126
9984
  const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
@@ -9673,9 +10531,15 @@ function sendRoutineResolution(res, result) {
9673
10531
  });
9674
10532
  return true;
9675
10533
  }
9676
- function resolveAndSendRoutine(res, args) {
10534
+ function resolveAndSendRoutine(res, args, ownersAnswer = false) {
9677
10535
  const card = store.messagesFor(args.threadId).find((message) => message.card?.requestId === args.requestId && message.card.routineRequest)?.card;
10536
+ // On a Cloud home a routine the owner approved on its card is theirs; one
10537
+ // it only changed stays whosever it was (cloudOwnerApplied).
10538
+ const operation = card?.routineRequest?.operation;
10539
+ const wasOwners = operation && operation.action !== "create" ? cloudRoutineIsOwners(operation.routineId) : false;
9678
10540
  const result = routineRequests.resolve(args);
10541
+ if (ownersAnswer && result.claimed && result.state === "applied")
10542
+ cloudOwnerApplied(result.action, result.resultId, wasOwners);
9679
10543
  if (result.claimed &&
9680
10544
  (result.state === "applied" || result.state === "denied")) {
9681
10545
  appendDecision(DATA_DIR, {
@@ -9811,12 +10675,25 @@ const webhooks = new WebhookManager({
9811
10675
  findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
9812
10676
  cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
9813
10677
  pendingRuns: (webhookId) => routines.activeWebhookRunCount(webhookId),
9814
- // delivery:"post" webhooks land in the bot's main chat as the bot's own message.
9815
- post: (botId, text) => {
9816
- const bot = store.bot(botId);
9817
- if (!bot)
10678
+ // delivery:"post" webhooks land in a dedicated "Updates" task, never
10679
+ // bot.threadId (the bot's currently-selected task) -- see
10680
+ // resolvePostThread below. Fixes
10681
+ // https://github.com/milind-soni/OpenMausBot/issues/2071: a post used to
10682
+ // land wherever the owner (or another automation) had last switched
10683
+ // that bot's selection, including a live conversation.
10684
+ post: (botId, threadId, text) => {
10685
+ if (!store.bot(botId))
9818
10686
  return;
9819
- store.appendMessage(bot.threadId, { role: "bot", kind: "text", text });
10687
+ store.appendMessage(threadId, { role: "bot", kind: "text", text });
10688
+ },
10689
+ // Mirrors resolveResultsThread's routines wiring a few hundred lines up
10690
+ // in this same file: create-on-first-use, never activated (so it never
10691
+ // steals the bot's live selection the way POST /api/bots/:id/tasks
10692
+ // does), reused forever after via trigger.resultsThreadId.
10693
+ resolvePostThread: (trigger, forceNew) => {
10694
+ if (!forceNew && trigger.resultsThreadId)
10695
+ return trigger.resultsThreadId;
10696
+ return store.createTask(trigger.botId, "Updates", false)?.threadId;
9820
10697
  },
9821
10698
  });
9822
10699
  let webhookIngress = null;
@@ -9903,6 +10780,33 @@ function serializeRoomContext(threadId, userName, textOverrides, readerBotId) {
9903
10780
  })
9904
10781
  .join("\n");
9905
10782
  }
10783
+ /** The decision model's view of a room message: the room, its people, the
10784
+ * last room lines before this one, and the message. Text is scrubbed of
10785
+ * secrets and attachment markup before it leaves this machine. */
10786
+ function roomRoutingInput(roomName, text, members, history, sender) {
10787
+ const userName = cfg.profile?.name?.trim() || "User";
10788
+ const clean = (value) => redactSecretsInText(extractTurnImages(value).text);
10789
+ const humans = new Set([userName]);
10790
+ const recent = history.flatMap((m) => {
10791
+ if (m.kind !== "text" || !m.text)
10792
+ return [];
10793
+ if (m.role === "user") {
10794
+ const person = m.sender?.name ?? userName;
10795
+ humans.add(person);
10796
+ return [{ from: person, text: clean(m.text) }];
10797
+ }
10798
+ return [{ from: `${m.from?.name ?? "A bot"} (bot)`, text: clean(m.text) }];
10799
+ });
10800
+ const from = sender?.name ?? userName;
10801
+ humans.add(from);
10802
+ return {
10803
+ room: roomName,
10804
+ humans: [...humans],
10805
+ members: members.map((member) => ({ id: member.id, name: member.name, title: member.title, description: member.description })),
10806
+ recent,
10807
+ message: { from, text: clean(text) },
10808
+ };
10809
+ }
9906
10810
  // What each room has already taken from its bots. Keyed by room because the
9907
10811
  // loop post_to_room can start is a property of the room, not of any one
9908
10812
  // caller — three bots posting twice each is the same runaway as one bot
@@ -9961,7 +10865,7 @@ setupRetry = 0) {
9961
10865
  // delegated Full elevation makes the two disagree by construction: every
9962
10866
  // such room turn then reads as "settings changed", retries once, and
9963
10867
  // settles as busy without ever dispatching.
9964
- const preparedApprovalMode = roomTurnApprovalMode(bot, orchestration);
10868
+ const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
9965
10869
  const preparedSelection = { ...bot.modelSelection };
9966
10870
  const preparedComposio = bot.composio;
9967
10871
  const instance = turnInstance(bot);
@@ -9971,8 +10875,14 @@ setupRetry = 0) {
9971
10875
  return true;
9972
10876
  }
9973
10877
  const roomPolicyRefusal = instance ? policyModelRefusal(instance) : undefined;
9974
- if (!instance || roomPolicyRefusal) {
9975
- const message = roomPolicyRefusal ?? `${bot.name}'s model is unavailable`;
10878
+ // A room turn a guest drives on a Cloud home is confined like a guest's
10879
+ // own conversation, or refused on an engine that cannot be.
10880
+ if (!groupSpeakers.has(threadId))
10881
+ guestDrivenTurns.delete(threadId);
10882
+ const roomGuestConfined = cloudGuestDriven(threadId) || cloudGuestDrivenHandoff(orchestration?.roomHandoffId);
10883
+ const roomGuestRefusal = roomGuestConfined && instance?.adapter.capabilities.guestTurns !== "confined" ? guestEngineRefusal(threadId) : undefined;
10884
+ if (!instance || roomPolicyRefusal || roomGuestRefusal) {
10885
+ const message = roomPolicyRefusal ?? roomGuestRefusal ?? `${bot.name}'s model is unavailable`;
9976
10886
  store.appendMessage(threadId, {
9977
10887
  role: "bot",
9978
10888
  kind: "activity",
@@ -10005,6 +10915,11 @@ setupRetry = 0) {
10005
10915
  return true;
10006
10916
  }
10007
10917
  const internalGeneration = beginInternalCapabilityGeneration(threadId);
10918
+ if (roomGuestConfined)
10919
+ guestDrivenTurns.set(threadId, internalGeneration);
10920
+ // A room turn is never provably the owner's alone (server/lending-memory.ts).
10921
+ if (CLOUD_HOME)
10922
+ noteForeignTurn(bot.id, threadId, internalGeneration);
10008
10923
  const resourceOwner = { threadId, generation: internalGeneration };
10009
10924
  turnResourceOwners.set(threadId, resourceOwner);
10010
10925
  let roomVmTarget = null;
@@ -10111,7 +11026,7 @@ setupRetry = 0) {
10111
11026
  }
10112
11027
  // user-configured MCP servers: same gating as the 1:1 site above.
10113
11028
  if (instance.adapter.capabilities.customMcp === true) {
10114
- const custom = engineMcpServers(bot);
11029
+ const custom = await withMcpSignIn(engineMcpServers(bot), mcpOAuth);
10115
11030
  if (Object.keys(custom).length)
10116
11031
  integrations.custom = custom;
10117
11032
  }
@@ -10133,7 +11048,7 @@ setupRetry = 0) {
10133
11048
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
10134
11049
  return false;
10135
11050
  const setupChanged = turnInstance(readyBot) !== instance ||
10136
- roomTurnApprovalMode(readyBot, orchestration) !== preparedApprovalMode ||
11051
+ roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
10137
11052
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
10138
11053
  readyBot.modelSelection.model !== preparedSelection.model ||
10139
11054
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -10244,13 +11159,14 @@ setupRetry = 0) {
10244
11159
  readyBot.browser !== false &&
10245
11160
  instance.adapter.capabilities.browserMcp === true,
10246
11161
  });
10247
- // A place the organisation disallows is refused before anything is
10248
- // provisioned or started, exactly as a bot thread refuses it.
11162
+ // A place the organisation disallows, or a Cloud home never offers, is
11163
+ // refused before anything is provisioned or started, exactly as a bot
11164
+ // thread refuses it.
10249
11165
  const roomKind = roomTeamComputer ? "box" : roomPlan.computer === "local" ? "thisComputer" : readyBot.computer === "vm" ? "localVm"
10250
11166
  : roomPlan.computer === "cloud" ? (turnProvider(readyBot) === "vps" ? "vps" : "box") : undefined;
10251
- const roomPlaceRefusal = roomKind && managedPolicy.computerRefusal(roomKind);
11167
+ const roomPlaceRefusal = roomKind && computerPlaceRefusal(roomKind);
10252
11168
  if (roomPlaceRefusal)
10253
- throw Object.assign(new Error(roomPlaceRefusal), { code: "managed_policy" });
11169
+ throw Object.assign(new Error(roomPlaceRefusal.message), { code: roomPlaceRefusal.code });
10254
11170
  // The Computer engine runs on the Boat; this computer has no tools it can
10255
11171
  // reach, exactly as a bot thread refuses it.
10256
11172
  if (roomPlan.computer === "local" && instance.adapter.capabilities.remoteAgent === true) {
@@ -10318,7 +11234,7 @@ setupRetry = 0) {
10318
11234
  }
10319
11235
  else {
10320
11236
  if (!boat.boatConfigured(cfg))
10321
- throw new Error("Cloud Boat is not configured — add a Boat API key or choose Local VM");
11237
+ throw new Error(BOAT_NOT_CONFIGURED);
10322
11238
  const remoteAgent = instance.adapter.capabilities.remoteAgent === true;
10323
11239
  const attached = await attachBotBoat(readyBot, resourceOwner, { explicitCloud: true,
10324
11240
  canMount: instance.adapter.capabilities.usesCloudComputer === true, remoteAgent });
@@ -10336,7 +11252,7 @@ setupRetry = 0) {
10336
11252
  // Claim the same lease as direct turns before asynchronous VM setup.
10337
11253
  if (readyBot.computer === "vm") {
10338
11254
  if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
10339
- throw new Error("this model engine cannot use the Local VM");
11255
+ throw new Error("this model cannot use the Local VM");
10340
11256
  }
10341
11257
  // A distinct identity fences cleanup even in shared mode on the same room thread.
10342
11258
  const target = { ...localVmTargetForThread(readyBot.id, threadId) };
@@ -10429,7 +11345,7 @@ setupRetry = 0) {
10429
11345
  const worksInWorkspace = supportsWorkspaceFiles(instance.driverKind);
10430
11346
  const workspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
10431
11347
  // a room member's memory writes are journaled the same as a 1:1 turn's
10432
- if (workspace)
11348
+ if (workspace && bot.memoryEnabled !== false)
10433
11349
  beginMemoryTurn(bot.id, threadId);
10434
11350
  // The room's folder pins here — on the first turn that actually
10435
11351
  // dispatches, not at PATCH time — so a folder set on a never-used room
@@ -10437,19 +11353,22 @@ setupRetry = 0) {
10437
11353
  // has its folder moved underneath it. Off-host members skip the folder
10438
11354
  // but must not decide the pin: the room's desk is a property of the
10439
11355
  // room, not of whichever member happened to speak first.
10440
- const cwd = groupTurnCwd(workspace, () => store.pinGroupCwd(readyGroup.id, threadId));
11356
+ // A room a guest opened on a Cloud home works in a folder of its own too.
11357
+ const cwd = workspace && cloudGuestOpened(threadId)
11358
+ ? ensureTaskWorkspace(bot.id, threadId)
11359
+ : groupTurnCwd(workspace, () => store.pinGroupCwd(readyGroup.id, threadId));
10441
11360
  {
10442
11361
  const drift = checkSoulDrift(bot.id, bot.soul ?? "", bot.soulHash ?? "");
10443
11362
  if (drift.drift !== Boolean(bot.soulDrift))
10444
11363
  store.patchBot(bot.id, { soulDrift: drift.drift });
10445
11364
  }
10446
- const roomMemory = memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace });
11365
+ const roomMemory = memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace, enabled: bot.memoryEnabled !== false });
10447
11366
  // The same brief a 1:1 turn gets: what this member said lately in its
10448
11367
  // other conversations, so a standup is answered from what happened. A
10449
11368
  // brief can carry a private chat into the room; as with session_search
10450
11369
  // (#754) the room is told, once per source thread, rather than the
10451
11370
  // crossing being blocked.
10452
- const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId });
11371
+ const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
10453
11372
  const roomComputerPromptKind = resolveComputerPromptKind({
10454
11373
  kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
10455
11374
  driverKind: instance.driverKind, cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
@@ -10472,9 +11391,10 @@ setupRetry = 0) {
10472
11391
  { id: "mcp", label: "MCP servers", text: customMcpPrompt(Object.keys(integrations.custom ?? {})) },
10473
11392
  { id: "computer", label: "Computer", text: computerPrompt(roomComputerPromptKind) },
10474
11393
  { id: "team-computer", label: "Team computer", text: teamComputerPrompt(roomTeamComputer) },
10475
- { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note }) },
11394
+ { id: "plan", label: "Surface", text: surfacePrompt({ computer: roomTeamComputer ? "cloud" : roomVmTarget ? "vm" : surfaceOfComputerKind(roomComputerKind), browser: Boolean(integrations.browser) }, { note: roomPlan.note, cloudHome: Boolean(CLOUD_HOME) }) },
11395
+ { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
10476
11396
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
10477
- { id: "recall", label: "Recall", text: integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
11397
+ { id: "recall", label: "Recall", text: integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "" },
10478
11398
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
10479
11399
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
10480
11400
  // the room path has always put a newline before memory and trimmed
@@ -10609,7 +11529,8 @@ setupRetry = 0) {
10609
11529
  text: withRecalled(roomRecalled, text),
10610
11530
  refreshSystemPrompt: true,
10611
11531
  images: turnImages,
10612
- approvalMode: roomTurnApprovalMode(readyBot, orchestration),
11532
+ approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
11533
+ ...(roomGuestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
10613
11534
  system: roomSystem.text,
10614
11535
  systemStable: roomSystem.stable,
10615
11536
  systemVolatile: roomSystem.volatile,
@@ -11131,6 +12052,13 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
11131
12052
  if (options.goalCoordinatorBotId && (channelMode !== "goal" || !requestedGoalCoordinator)) {
11132
12053
  throw Object.assign(new Error("the selected goal coordinator is not an active room member"), { status: 409 });
11133
12054
  }
12055
+ // An Auto room may ask the decision model who answers. It reads the room
12056
+ // as it stood before this message, which is appended just below.
12057
+ const autoCandidate = !group.dm && channelMode === "chat" && group.defaultResponder.kind === "auto" &&
12058
+ deciderReady(cfg, "roomRouting");
12059
+ const historyBeforeSend = autoCandidate
12060
+ ? store.messagesFor(threadId).filter((m) => m.kind === "text" && m.text).slice(-GROUP_CONTEXT_MESSAGES)
12061
+ : [];
11134
12062
  // A coalesced drain appends every queued line under its own queueId (the
11135
12063
  // chips and sendId receipts stay per-item); the LAST line is the turn's
11136
12064
  // user message, exactly like the 1:1 drain's userMessage.
@@ -11223,25 +12151,42 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
11223
12151
  }
11224
12152
  return message;
11225
12153
  }
12154
+ // Auto with nobody addressed: the responders above are only the fallback
12155
+ // until the decision model answers, in the async round below. The append
12156
+ // and this function's return stay synchronous either way.
12157
+ const autoRoute = autoCandidate && !goalCoordinator && availableMembers.length > 1 &&
12158
+ roomResponders(text, availableMembers, { kind: "mentions" }).length === 0;
11226
12159
  // The snippet is only the fallback name here too. The member about to
11227
12160
  // answer supplies the same cheap one-shot the bot path uses, and the
11228
12161
  // swap lands only while the row still carries the snippet — a rename by
11229
12162
  // the person always wins. Channel tasks carry no peer provenance (no
11230
12163
  // assignment opens them), and a member whose engine offers no text
11231
12164
  // one-shot simply keeps the snippet.
11232
- const titleBot = goalCoordinator ?? responders[0];
11233
- const titleInstance = registry.get(titleBot.modelSelection.instanceId);
11234
12165
  const titleText = extractTurnImages(text).text;
11235
- // An engine the organisation disallows never receives the text, even for a title.
11236
- if (titled && snippet && titleText.trim() && llmThreadTitlesEnabled(cfg) && titleInstance?.generateText && !policyModelRefusal(titleInstance)) {
11237
- void generateThreadTitle(titleInstance, titleText)
11238
- .then((title) => {
11239
- if (title)
11240
- store.retitleGroupTask(group.id, threadId, snippet, title);
12166
+ const startTitle = (titleBot) => {
12167
+ const titleInstance = registry.get(titleBot.modelSelection.instanceId);
12168
+ // An engine the organisation disallows never receives the text, even for a title.
12169
+ if (titled && snippet && titleText.trim() && llmThreadTitlesEnabled(cfg) && titleInstance?.generateText && !policyModelRefusal(titleInstance)) {
12170
+ void generateThreadTitle(titleInstance, titleText)
12171
+ .then((title) => {
12172
+ if (title)
12173
+ store.retitleGroupTask(group.id, threadId, snippet, title);
12174
+ })
12175
+ .catch(() => undefined);
12176
+ }
12177
+ };
12178
+ // An Auto round titles with whoever the decision picks, once it has.
12179
+ if (!autoRoute)
12180
+ startTitle(goalCoordinator ?? responders[0]);
12181
+ const operation = beginGroupTurnOperation(groupId, threadId, goalCoordinator || autoRoute ? [] : responders.map((responder) => responder.id));
12182
+ // Asked now, in parallel with any earlier room work still queued: the
12183
+ // answer is waited for only when this round's turn comes up. It never
12184
+ // rejects; a Stop aborts it through the operation's signal.
12185
+ const routing = autoRoute
12186
+ ? decideRoomResponder(decider, roomRoutingInput(group.name, text, availableMembers, historyBeforeSend, options.sender), {
12187
+ signal: operation.cancellation.signal,
11241
12188
  })
11242
- .catch(() => undefined);
11243
- }
11244
- const operation = beginGroupTurnOperation(groupId, threadId, goalCoordinator ? [] : responders.map((responder) => responder.id));
12189
+ : null;
11245
12190
  if (queuedGroup && queuedGroup.length > 0) {
11246
12191
  // member turns use these stable ids to carry every burst line's words
11247
12192
  // and attachments through the turn that swallowed the burst
@@ -11298,9 +12243,27 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
11298
12243
  await runGroupGoalOperation({ groupId, threadId, coordinator: goalCoordinator, members, operation });
11299
12244
  }
11300
12245
  else {
12246
+ let round = responders;
12247
+ let routedBy;
12248
+ if (routing) {
12249
+ const route = await routing;
12250
+ if (operation.cancelled)
12251
+ return;
12252
+ const picked = route.kind === "member" ? availableMembers.filter((member) => member.id === route.botId)
12253
+ : route.kind === "everyone" ? availableMembers
12254
+ : [];
12255
+ if (picked.length && route.kind !== "fallback") {
12256
+ round = picked;
12257
+ routedBy = { provider: "jev", probability: Math.round(route.probability * 100) / 100 };
12258
+ }
12259
+ // What a routed round holds from the start, like any other round.
12260
+ for (const responder of round)
12261
+ operation.botIds.add(responder.id);
12262
+ startTitle(round[0]);
12263
+ }
11301
12264
  const spoken = new Set();
11302
12265
  const skillAuthoringClaim = { claimed: false };
11303
- for (const responder of responders) {
12266
+ for (const responder of round) {
11304
12267
  if (operation.cancelled)
11305
12268
  break;
11306
12269
  if (spoken.has(responder.id))
@@ -11316,7 +12279,19 @@ function startGroupTurn(groupId, text, replyTo, sendId, channelMode = "chat", qu
11316
12279
  spoken.add(responder.id);
11317
12280
  continue;
11318
12281
  }
11319
- if (!(await runGroupMemberTurn(groupId, threadId, responder.id, 0, spoken, undefined, undefined, () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation), skillAuthoringClaim, undefined, operation)))
12282
+ // This speaker's first reply line says the decision model chose it.
12283
+ const stamp = routedBy ? { botId: responder.id, routedBy } : undefined;
12284
+ if (stamp)
12285
+ routedRoomReplies.set(threadId, stamp);
12286
+ let proceed;
12287
+ try {
12288
+ proceed = await runGroupMemberTurn(groupId, threadId, responder.id, 0, spoken, undefined, undefined, () => operation.cancelled, () => groupProviderHandshakeStarted(operation), () => groupProviderHandshakeSettled(operation), skillAuthoringClaim, undefined, operation);
12289
+ }
12290
+ finally {
12291
+ if (stamp && routedRoomReplies.get(threadId) === stamp)
12292
+ routedRoomReplies.delete(threadId);
12293
+ }
12294
+ if (!proceed)
11320
12295
  break;
11321
12296
  }
11322
12297
  }
@@ -12188,24 +13163,15 @@ async function testCliBinary(cli, driver) {
12188
13163
  });
12189
13164
  }
12190
13165
  /** A pre-save probe only needs PATH. Never hand credentials inherited by the
12191
- * desktop/server process to an arbitrary wrapper selected through Settings. */
13166
+ * desktop/server process to an arbitrary wrapper selected through Settings.
13167
+ * The shared lists (config.ts) cover every workspace, control-plane and
13168
+ * provider credential, including ones added later; the probe and `claude
13169
+ * update` keep everything else (HOME, proxy settings, CA bundles). */
12192
13170
  function cliProbeEnvironment() {
12193
13171
  const env = { ...process.env, PATH: augmentedPath() };
12194
- for (const key of [
12195
- "XAI_API_KEY",
12196
- "BOX_TOKEN",
12197
- "OPENCODE_API_KEY",
12198
- "COMPOSIO_API_KEY",
12199
- "OMB_COMPOSIO_BROKER_TOKEN",
12200
- "OMB_TTS_KEY",
12201
- "OMB_FISH_AUDIO_API_KEY",
12202
- "OMB_OPENAI_IMAGE_KEY",
12203
- "OMB_CUSTOM_IMAGE_KEY",
12204
- "ANTHROPIC_API_KEY",
12205
- "OPENAI_API_KEY",
12206
- ]) {
13172
+ stripWorkspaceCredentialEnv(env);
13173
+ for (const key of PROVIDER_CREDENTIAL_ENV)
12207
13174
  delete env[key];
12208
- }
12209
13175
  return env;
12210
13176
  }
12211
13177
  /** execFile's error carries the child's stderr in .stderr. */
@@ -12213,12 +13179,12 @@ function stderrOf(err) {
12213
13179
  const s = err.stderr;
12214
13180
  return typeof s === "string" ? s : Buffer.isBuffer(s) ? s.toString("utf8") : "";
12215
13181
  }
12216
- async function localVmPayload(target) {
13182
+ async function localVmPayload(target, auth) {
12217
13183
  const status = await containerComputerStatus(undefined, undefined, target);
12218
13184
  return {
12219
- ...status,
13185
+ ...localVmViewerStatus(status, auth),
12220
13186
  commands: setupCommands(status.runtime, process.platform, target),
12221
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
13187
+ idle_timeout_ms: localVmIdleMs(),
12222
13188
  mode: localVmMode(cfg),
12223
13189
  max_instances: localVmMaxInstances(cfg),
12224
13190
  };
@@ -12346,7 +13312,9 @@ function configStatus() {
12346
13312
  return {
12347
13313
  xai: { configured: Boolean(cfg.xai?.key) },
12348
13314
  mistral: { configured: Boolean(cfg.mistral?.key) },
12349
- anthropic: { configured: Boolean(cfg.anthropic?.key) },
13315
+ anthropic: { configured: Boolean(cfg.anthropic?.key), everyClaudeBot: cfg.anthropic?.everyClaudeBot !== false },
13316
+ openai: { configured: Boolean(cfg.openai?.key) },
13317
+ openrouter: { configured: Boolean(cfg.openrouter?.key) },
12350
13318
  // a fleet agent on this server means Settings → Workspaces has something to drive
12351
13319
  fleet: { available: fleetAvailable(fleetSocketPath()) },
12352
13320
  // what this build is entitled to, so Settings shows only what works here
@@ -12365,17 +13333,24 @@ function configStatus() {
12365
13333
  configured: composio.configured(cfg),
12366
13334
  mode: composio.connectionMode(cfg),
12367
13335
  },
12368
- box: { configured: Boolean(cfg.box?.token) },
13336
+ // configured = cloud computers work here; included = through Cloud Pro,
13337
+ // not a saved key
13338
+ box: boat.describeBoatAccount(cfg),
12369
13339
  vps: { configured: Boolean(vpsSshAlias(cfg)), sshAlias: vpsSshAlias(cfg) ?? "" },
12370
13340
  opencodeGo: { configured: Boolean(cfg.opencodeGo?.apiKey) },
12371
13341
  // the chosen voice is a setting, not a secret; the key is reported the
12372
13342
  // same configured-or-not way as every other credential
12373
13343
  tts: tts.describeVoice(cfg),
13344
+ // the decision model: switches and configured-or-not, never the key
13345
+ decider: describeDecider(cfg),
12374
13346
  imageGen: avatarImageStatus(cfg),
12375
13347
  // not a secret — the sidebar shows it
12376
13348
  profile: { name: cfg.profile?.name ?? "", email: cfg.profile?.email ?? "", aboutMe: cfg.profile?.aboutMe ?? "" },
12377
13349
  // the enrolled organisation's read-only desktop policy; null when not enrolled
12378
13350
  managedPolicy: managedPolicy.summary(),
13351
+ // an OMB Cloud home (cloud-home.ts): the app offers no this computer and
13352
+ // no Local VM here
13353
+ ...(CLOUD_HOME ? { cloudHome: true } : {}),
12379
13354
  // not a secret — the settings picker shows it; "" = follow the system
12380
13355
  language: cfg.language ?? "",
12381
13356
  rooms: { turnTimeoutMinutes: roomTurnTimeoutMinutes(cfg) },
@@ -12390,6 +13365,7 @@ function configStatus() {
12390
13365
  localVm: {
12391
13366
  mode: localVmMode(cfg),
12392
13367
  maxInstances: localVmMaxInstances(cfg),
13368
+ idleTimeoutMinutes: localVmIdleTimeoutMinutes(cfg),
12393
13369
  },
12394
13370
  features: {
12395
13371
  skillAuthoring: skillAuthoringEnabled(cfg),
@@ -12404,6 +13380,8 @@ function configStatus() {
12404
13380
  // own Claude Code MCP servers
12405
13381
  claudeUserMcp: claudeUserMcpEnabled(cfg),
12406
13382
  autoRecall: autoRecallEnabled(cfg),
13383
+ // hand-edited config.json only; the thread menu offers Regenerate title from it
13384
+ llmThreadTitles: llmThreadTitlesEnabled(cfg),
12407
13385
  },
12408
13386
  // first-run progress — not a secret; the app decides whether to show
12409
13387
  // the welcome tour from this, never from browser storage
@@ -12412,6 +13390,7 @@ function configStatus() {
12412
13390
  version: cfg.onboarding?.version ?? 0,
12413
13391
  reelSeen: cfg.onboarding?.reelSeen === true,
12414
13392
  hintsSeen: cfg.onboarding?.hintsSeen ?? [],
13393
+ ...(cfg.onboarding?.firstTurnAt ? { firstTurnAt: cfg.onboarding.firstTurnAt } : {}),
12415
13394
  },
12416
13395
  // Which browser this server can give bots: the desktop app's surface,
12417
13396
  // the agent-browser engine, or nothing yet (with the reason).
@@ -12445,7 +13424,14 @@ function mcpServerResponse() {
12445
13424
  // While enrolled with custom servers off, say which servers stay configured
12446
13425
  // but never reach bots, and why. Nothing here is written to config.json.
12447
13426
  const policy = managedPolicy.current();
12448
- const servers = listMcpServers(cfg.mcpServers).map(server => managedPolicy.mcpAllowed(server.name, "url" in server ? server.url : undefined) ? server : { ...server, managedBy: policy.organizationName });
13427
+ const servers = listMcpServers(cfg.mcpServers).map((server) => {
13428
+ if (!("url" in server))
13429
+ return server;
13430
+ const auth = mcpOAuth.authState(server.name, server.url);
13431
+ // the redirect URI to register with a pre-registered sign-in app
13432
+ const listed = server.oauth ? { ...server, oauth: { ...server.oauth, redirectUri: mcpOAuthRedirectUri(server.url) } } : server;
13433
+ return auth === "none" ? listed : { ...listed, auth };
13434
+ }).map(server => managedPolicy.mcpAllowed(server.name, "url" in server ? server.url : undefined) ? server : { ...server, managedBy: policy.organizationName });
12449
13435
  return { servers, ...(policy && !policy.mcp.allowCustom ? { managed: { organizationName: policy.organizationName, allowlist: policy.mcp.allowlist } } : {}) };
12450
13436
  }
12451
13437
  /** Adding or editing a server the organisation has not approved is refused. */
@@ -12460,7 +13446,7 @@ function mcpServerBody(body) {
12460
13446
  if (!body || typeof body !== "object")
12461
13447
  return out;
12462
13448
  const record = body;
12463
- for (const key of ["command", "args", "env", "type", "url", "headers", "enabled"]) {
13449
+ for (const key of ["command", "args", "env", "type", "url", "headers", "oauth", "enabled"]) {
12464
13450
  if (record[key] !== undefined)
12465
13451
  out[key] = record[key];
12466
13452
  }
@@ -12469,7 +13455,7 @@ function mcpServerBody(body) {
12469
13455
  /** Configured MCP servers that may reach this bot's engine. While enrolled,
12470
13456
  * the organisation's allow-list filters them; config.json is never changed. */
12471
13457
  function engineMcpServers(bot) {
12472
- return managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers));
13458
+ return withoutPendingSignIn(managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers)), mcpOAuth);
12473
13459
  }
12474
13460
  function persistMcpServers(next) {
12475
13461
  saveConfig({ mcpServers: next });
@@ -12483,7 +13469,11 @@ async function describeInstances() {
12483
13469
  const configs = providerConfigs();
12484
13470
  return (await registry.describe()).map((instance) => {
12485
13471
  const entry = configs[instance.instanceId];
12486
- const described = entry?.icon ? { ...instance, icon: entry.icon } : instance;
13472
+ const described = {
13473
+ ...instance,
13474
+ ...(entry?.icon ? { icon: entry.icon } : {}),
13475
+ ...(entry?.access ? { access: entry.access } : {}),
13476
+ };
12487
13477
  if (hostedModels)
12488
13478
  return { ...described, readOnly: true,
12489
13479
  install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
@@ -12495,8 +13485,16 @@ async function describeInstances() {
12495
13485
  ...described, ...policy, readOnly: true, managed: managedDesktop.info(instance.instanceId),
12496
13486
  install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
12497
13487
  };
13488
+ // Windows keeps what Antigravity's runtime unpacked whenever it is
13489
+ // stopped by force; its Settings card offers to clear that.
13490
+ if (instance.driverKind === "antigravityAgent" && process.platform === "win32")
13491
+ return { ...described, ...policy, freeUpSpace: true };
12498
13492
  if (entry?.driver !== "claudeAgent")
12499
13493
  return { ...described, ...policy };
13494
+ // Claude on the workspace API key has no account to sign in to.
13495
+ if (instance.instanceId === CLAUDE_API_INSTANCE) {
13496
+ return { ...described, ...policy, authentication: undefined, install: { ...instance.install, signInCommand: undefined } };
13497
+ }
12500
13498
  try {
12501
13499
  const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
12502
13500
  return { ...described, ...policy, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
@@ -12635,6 +13633,7 @@ async function reloadProviders() {
12635
13633
  stopScreenPoller(botId, threadId);
12636
13634
  releaseLocalVmThread(threadId);
12637
13635
  releaseTurnResources(owner);
13636
+ endForeignTurns(threadId);
12638
13637
  vpsThreadEnded(botId, threadId);
12639
13638
  watchdog.settle(threadId);
12640
13639
  closeOpenApprovals(threadId);
@@ -12654,6 +13653,7 @@ async function reloadProviders() {
12654
13653
  for (const [threadId, speaker] of rooms) {
12655
13654
  releaseLocalVmThread(threadId);
12656
13655
  releaseTurnResources(turnResourceOwners.get(threadId));
13656
+ endForeignTurns(threadId);
12657
13657
  watchdog.settle(threadId);
12658
13658
  closeOpenApprovals(threadId);
12659
13659
  if (groupSpeakers.get(threadId) !== speaker)
@@ -12685,6 +13685,10 @@ let mcpProbesInFlight = 0;
12685
13685
  // install, and running two self-updates against it would race its files.
12686
13686
  const claudeUpdatesInFlight = new Set();
12687
13687
  // ── HTTP plumbing ─────────────────────────────────────────────────────
13688
+ /** The web UI's pages: never framed by another page (a hidden frame could
13689
+ * otherwise act on a pair or sign-in page unseen), and they send no Referer.
13690
+ * Nothing frames the web UI: the desktop app shows it in its own window. */
13691
+ const PAGE_HEADERS = { "content-security-policy": "frame-ancestors 'none'", "x-frame-options": "DENY", "referrer-policy": "no-referrer" };
12688
13692
  /** The built UI, when this process serves it (OMB_STATIC_DIR: set by the
12689
13693
  * desktop app and by the container image). Public by design: it is the same
12690
13694
  * bundle anyone can download, holds no secrets, and a remote browser must be
@@ -12697,7 +13701,8 @@ function serveStatic(res, path) {
12697
13701
  const file = join(STATIC_DIR, safe);
12698
13702
  try {
12699
13703
  const data = readFileSync(file);
12700
- res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
13704
+ const type = MIME[extname(file)] ?? "application/octet-stream";
13705
+ res.writeHead(200, { "content-type": type, ...(type === "text/html" ? PAGE_HEADERS : {}) });
12701
13706
  res.end(data);
12702
13707
  return true;
12703
13708
  }
@@ -12705,7 +13710,7 @@ function serveStatic(res, path) {
12705
13710
  // SPA fallback
12706
13711
  try {
12707
13712
  const data = readFileSync(join(STATIC_DIR, "index.html"));
12708
- res.writeHead(200, { "content-type": "text/html" });
13713
+ res.writeHead(200, { "content-type": "text/html", ...PAGE_HEADERS });
12709
13714
  res.end(data);
12710
13715
  return true;
12711
13716
  }
@@ -12718,12 +13723,7 @@ function serveStatic(res, path) {
12718
13723
  // requests from any loopback connection and any web page that DNS-rebinds
12719
13724
  // onto it. Reject non-loopback Hosts outright (defeats rebinding) and
12720
13725
  // origins outside loopback (blocks remote-web CSRF).
12721
- const workspaceBackupRoutes = createWorkspaceBackupRoutes({
12722
- dataDir: DATA_DIR,
12723
- appVersion: serverVersion(),
12724
- readBody,
12725
- restored: workspaceRestore,
12726
- status: () => ({ busy: workspaceMaintenance.active, pendingRestore: workspaceMaintenance.pendingRestore }),
13726
+ const workspaceBackupAccess = {
12727
13727
  authorized: (req, original) => {
12728
13728
  const current = resolveRequestAuth(req, {
12729
13729
  sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events",
@@ -12759,6 +13759,27 @@ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
12759
13759
  closeMessageDb();
12760
13760
  },
12761
13761
  }, keepLocked),
13762
+ status: () => ({ busy: workspaceMaintenance.active, pendingRestore: workspaceMaintenance.pendingRestore }),
13763
+ };
13764
+ const workspaceBackupRoutes = createWorkspaceBackupRoutes({
13765
+ dataDir: DATA_DIR,
13766
+ appVersion: serverVersion(),
13767
+ readBody,
13768
+ restored: workspaceRestore,
13769
+ ...workspaceBackupAccess,
13770
+ });
13771
+ // Move to Cloud (server/cloud-move-http.ts): every server sizes its own
13772
+ // workspace for the desktop; only a Cloud home receives one. After its
13773
+ // restore commits, the Cloud home restarts so startup installs it.
13774
+ let cloudHomeRestartRequested = false;
13775
+ const cloudMoveRoutes = createCloudMoveRoutes({
13776
+ dataDir: DATA_DIR,
13777
+ appVersion: serverVersion(),
13778
+ cloudHome: Boolean(CLOUD_HOME),
13779
+ readBody,
13780
+ restored: workspaceRestore,
13781
+ ...workspaceBackupAccess,
13782
+ restart: () => { cloudHomeRestartRequested = true; gracefulShutdown(); },
12762
13783
  });
12763
13784
  // Route modules (server/routes/README.md). `workspaceAccess` is assigned at
12764
13785
  // boot, after this line, so the dependency reads it per request.
@@ -12773,6 +13794,23 @@ ROUTES.push(createBotMemoryRoutes({
12773
13794
  bot: (id) => store.bot(id),
12774
13795
  taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
12775
13796
  upkeep: memoryUpkeep,
13797
+ ...(lendingMemory ? {
13798
+ lendingReview: {
13799
+ needed: (botId) => {
13800
+ if (!lendingMemory.reconcile(botId, foreignTurnRunning(botId), { cached: true }).changedBySomeoneElse)
13801
+ return null;
13802
+ const { token, changed } = lendingMemory.reviewInfo(botId);
13803
+ return { token, changed };
13804
+ },
13805
+ accept: (botId, token) => lendingMemory.review(botId, foreignTurnRunning(botId), token).ok,
13806
+ // Like Move to Cloud: one of the owner's own devices, never loopback
13807
+ // (on a Cloud home that is a bot's shell, not the owner).
13808
+ owner: cloudOwnerSession,
13809
+ },
13810
+ // The owner's own Memory edits are theirs even while someone else's
13811
+ // conversation runs; anyone else's are judged like a bot's own write.
13812
+ ownersWrite: (auth, botId, write) => cloudOwnerSession(auth) ? lendingMemory.trustedWrite(botId, write) : write(),
13813
+ } : {}),
12776
13814
  aboutMe: () => cfg.profile?.aboutMe ?? "",
12777
13815
  saveAboutMe: (aboutMe) => {
12778
13816
  saveConfig({ profile: { ...cfg.profile, aboutMe } });
@@ -12780,6 +13818,14 @@ ROUTES.push(createBotMemoryRoutes({
12780
13818
  broadcast({ kind: "config", ...configStatus() });
12781
13819
  },
12782
13820
  }));
13821
+ ROUTES.push(createDeciderRoutes({ decider }));
13822
+ ROUTES.push(createAntigravityLeftoverRoutes({
13823
+ hosted: Boolean(hostedModels),
13824
+ isAntigravity: (instanceId) => registry.get(instanceId)?.driverKind === "antigravityAgent",
13825
+ find: () => findAntigravityLeftovers(),
13826
+ remove: () => removeAntigravityLeftovers(),
13827
+ }));
13828
+ ROUTES.push(desktopViewer.route);
12783
13829
  const toolResults = new ToolResults();
12784
13830
  const handleRequest = async (req, res) => {
12785
13831
  let url;
@@ -12836,7 +13882,7 @@ const handleRequest = async (req, res) => {
12836
13882
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
12837
13883
  return;
12838
13884
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
12839
- return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && emailSignIn.enabled(), sharedComputers: sharedComputersEnabled(cfg) }));
13885
+ return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
12840
13886
  }
12841
13887
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
12842
13888
  if (method === "GET" && domainCheck) {
@@ -12846,7 +13892,11 @@ const handleRequest = async (req, res) => {
12846
13892
  }
12847
13893
  // Sign in with an emailed code (server/account-signin.ts). Public like
12848
13894
  // /api/auth/pair, JSON-only for the same reason, and counted against the
12849
- // same per-source lockout so a code cannot be guessed.
13895
+ // same per-source lockout so a code cannot be guessed. Never on a Cloud
13896
+ // home: it is personal, and the owner's devices sign in through the Admin.
13897
+ if (CLOUD_HOME && method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
13898
+ return json(res, 403, { error: CLOUD_PERSONAL_REFUSAL, code: "cloud_personal" });
13899
+ }
12850
13900
  if (method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
12851
13901
  if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
12852
13902
  return json(res, 415, { error: "send the sign-in request as JSON (content-type: application/json)" });
@@ -12879,7 +13929,7 @@ const handleRequest = async (req, res) => {
12879
13929
  }
12880
13930
  sessions.clearFailures(source);
12881
13931
  const issued = sessions.issue({ label: label.trim() || labelFromUserAgent(req.headers["user-agent"]), scopes: verified.scopes, userId: verified.userId, email: verified.email });
12882
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: true, sharedComputers: sharedComputersEnabled(cfg) });
13932
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: true, sharedComputers: lendingEnabled() });
12883
13933
  const secure = requestOrigin(req)?.startsWith("https://") === true;
12884
13934
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, issued.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(issued.session) }));
12885
13935
  return json(res, 200, { session: issued.session, environment });
@@ -12916,13 +13966,27 @@ const handleRequest = async (req, res) => {
12916
13966
  const wantsCookie = body?.cookie === true;
12917
13967
  const label = typeof body?.label === "string" ? body.label : "";
12918
13968
  const attemptId = typeof body?.attemptId === "string" ? body.attemptId : undefined;
12919
- const result = sessions.exchange({ code, label, attemptId, source: requestSource(req), fallbackLabel: labelFromUserAgent(req.headers["user-agent"]) });
13969
+ // A browser sign-in (the OMB Cloud page's "Use in your browser", docs/cloud-pro.md)
13970
+ // redeems only a window opened for one, and only into this browser's cookie.
13971
+ const browser = body?.browser === true;
13972
+ if (browser && body?.preview === true) {
13973
+ // Whose Cloud it signs in to, shown before the person continues. Redeems nothing.
13974
+ const preview = sessions.previewBrowserSignIn(code);
13975
+ return preview ? json(res, 200, preview) : json(res, 401, { error: "this sign-in link has expired or was already used" });
13976
+ }
13977
+ if (browser && !wantsCookie)
13978
+ return json(res, 400, { error: "a browser sign-in sets this browser's cookie; send cookie: true" });
13979
+ const result = sessions.exchange({ code, label, attemptId, browser, source: requestSource(req), fallbackLabel: labelFromUserAgent(req.headers["user-agent"]) });
12920
13980
  if (!result.ok) {
12921
13981
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
12922
13982
  return json(res, result.status, { error: result.error });
12923
13983
  }
12924
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled(), sharedComputers: sharedComputersEnabled(cfg) });
13984
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() });
12925
13985
  if (wantsCookie) {
13986
+ // A browser sign-in replaces this browser's own session here, if it had one, rather than leaving it behind.
13987
+ const previous = browser ? sessions.authenticate(parseCookies(req.headers.cookie).get(SESSION_COOKIE)) : null;
13988
+ if (previous && previous.id !== result.session.id)
13989
+ sessions.revoke(previous.id);
12926
13990
  const secure = requestOrigin(req)?.startsWith("https://") === true;
12927
13991
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
12928
13992
  return json(res, 200, { session: result.session, environment });
@@ -12973,7 +14037,7 @@ const handleRequest = async (req, res) => {
12973
14037
  url,
12974
14038
  loopbackMutationToken: desktopMutationToken,
12975
14039
  companionMutationToken,
12976
- features: { sharedComputers: sharedComputersEnabled(cfg) },
14040
+ features: { sharedComputers: lendingEnabled() },
12977
14041
  loopbackTrust: LOOPBACK.trust,
12978
14042
  cliOwnerToken,
12979
14043
  });
@@ -13031,6 +14095,8 @@ const handleRequest = async (req, res) => {
13031
14095
  }
13032
14096
  if (await workspaceBackupRoutes(req, res, path, auth))
13033
14097
  return;
14098
+ if (await cloudMoveRoutes(req, res, path, auth))
14099
+ return;
13034
14100
  // Count ordinary requests until their asynchronous handler returns, not
13035
14101
  // merely until the browser disconnects. A cancelled upload can still write.
13036
14102
  if (path.startsWith("/api/") && path !== "/api/events" && path !== "/api/health" && !path.startsWith("/api/shared-computers/") && !isWorkspaceBackupSessionControl(method, path)) {
@@ -13054,12 +14120,18 @@ const handleRequest = async (req, res) => {
13054
14120
  environmentId: ENVIRONMENT_ID,
13055
14121
  // the account behind the session, when it came from a sign-in
13056
14122
  ...(auth.session.email ? { email: auth.session.email } : {}),
14123
+ // whose OMB Cloud a browser sign-in signed in to
14124
+ ...(auth.session.owner ? { owner: auth.session.owner } : {}),
13057
14125
  // a hosted team workspace: the web UI's first run skips the
13058
14126
  // desktop-only beats there. Absent everywhere else.
13059
14127
  ...(HOSTED_WORKSPACE ? { hosted: true } : {}),
13060
14128
  // an OMB Cloud home: the web UI's first run is its engine
13061
14129
  // sign-in (docs/cloud-pro.md). Absent everywhere else.
13062
14130
  ...(CLOUD_HOME ? { cloudHome: true } : {}),
14131
+ // a device that is not one of the owner's own on a Cloud home
14132
+ // writes only in conversations it opened: the composer offers
14133
+ // a new conversation everywhere else.
14134
+ ...(CLOUD_HOME && !cloudOwnerSession(auth) ? { cloudGuest: true, openedThreads: threadStarters.threadsOf(actorKey(auth), 2_000) } : {}),
13063
14135
  });
13064
14136
  }
13065
14137
  if (method === "POST" && path === "/api/auth/stream-ticket") {
@@ -13083,7 +14155,16 @@ const handleRequest = async (req, res) => {
13083
14155
  }
13084
14156
  const requested = body?.scopes;
13085
14157
  const scopes = Array.isArray(requested) ? requested.filter((v) => v === "admin" || v === "client") : undefined;
13086
- const opened = sessions.openPairing({ label: typeof body?.label === "string" ? body.label : undefined, scopes });
14158
+ let opened;
14159
+ try {
14160
+ opened = sessions.openPairing({ label: typeof body?.label === "string" ? body.label : undefined, scopes });
14161
+ }
14162
+ catch (error) {
14163
+ // A Cloud home is personal: a device paired to it has admin scope (sessions.requireAdmin).
14164
+ if (error.code === "cloud_personal")
14165
+ return json(res, 403, { error: error.message, code: "cloud_personal" });
14166
+ throw error;
14167
+ }
13087
14168
  const origin = requestOrigin(req);
13088
14169
  const base = publicUrl() ?? (auth.kind === "session" && origin ? origin : null);
13089
14170
  const code = formatPairingCode(opened.code);
@@ -13150,11 +14231,15 @@ const handleRequest = async (req, res) => {
13150
14231
  const cancelled = sessions.cancelPairing(m[1]);
13151
14232
  return json(res, cancelled ? 200 : 404, cancelled ? { ok: true } : { error: "no such pairing code" });
13152
14233
  }
13153
- if (method === "POST" && path === "/api/desktop/shared-computer-control" && sharedComputersEnabled(cfg)) {
14234
+ // The desktop's own server always answers its desktop here: lending to the
14235
+ // person's Cloud (docs/cloud-pro.md) needs the lease without the
14236
+ // maintainer flag. It grants nothing by itself; it only keeps local bot
14237
+ // turns and a remote screen action from using the screen at once.
14238
+ if (method === "POST" && path === "/api/desktop/shared-computer-control" && (sharedComputersEnabled(cfg) || DESKTOP_MANAGED)) {
13154
14239
  if (auth.kind !== "loopback")
13155
14240
  return json(res, 403, { error: "Local desktop only" });
13156
14241
  const body = await readBody(req, 1024);
13157
- if (!sharedComputersEnabled(cfg))
14242
+ if (!sharedComputersEnabled(cfg) && !DESKTOP_MANAGED)
13158
14243
  return json(res, 404, { error: `no route: ${method} ${path}` });
13159
14244
  if (!z.string().uuid().safeParse(body?.id).success || !["acquire", "release", "renew"].includes(body?.action))
13160
14245
  return json(res, 400, { error: "Invalid computer lease" });
@@ -13177,13 +14262,19 @@ const handleRequest = async (req, res) => {
13177
14262
  // With features.sharedComputers off the whole family falls through to the
13178
14263
  // generic "no route" 404, so a probe cannot tell a disabled feature from
13179
14264
  // a build that never had one.
13180
- if (method === "POST" && path.startsWith("/api/shared-computers/") && sharedComputersEnabled(cfg)) {
14265
+ // What the asking person has lent to this server, online or not, with its
14266
+ // scopes (docs/cloud-pro.md, "What the Cloud can see"). Owner-scoped like
14267
+ // the bot routes; no id a session could not already use, and no secret.
14268
+ if (method === "GET" && path === "/api/shared-computers" && lendingEnabled()) {
14269
+ return json(res, 200, { computers: sharedComputers.status(sessionLendingPrincipal(auth)) });
14270
+ }
14271
+ if (method === "POST" && path.startsWith("/api/shared-computers/") && lendingEnabled()) {
13181
14272
  if (auth.kind !== "session")
13182
14273
  return json(res, 403, { error: "Pair this desktop first" });
13183
14274
  if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? "")))
13184
14275
  return json(res, 415, { error: "JSON required" });
13185
14276
  const body = await readBody(req, 4_000_000);
13186
- if (!sharedComputersEnabled(cfg))
14277
+ if (!lendingEnabled())
13187
14278
  return json(res, 404, { error: `no route: ${method} ${path}` });
13188
14279
  if (!sessions.isLive(auth.session.id))
13189
14280
  return json(res, 401, { error: "Session ended" });
@@ -13195,7 +14286,11 @@ const handleRequest = async (req, res) => {
13195
14286
  const registration = parsed.data;
13196
14287
  if (registration.environmentId !== ENVIRONMENT_ID)
13197
14288
  return json(res, 409, { error: "Workspace identity changed. Pair again before sharing this computer." });
13198
- sharedComputers.register(registration, auth.session.id, secret);
14289
+ // A Cloud home is one person's: only their own devices, which the
14290
+ // Admin's pairing signs in with admin scope, may lend to it.
14291
+ if (CLOUD_HOME && !auth.scopes.includes("admin"))
14292
+ return json(res, 403, { error: "Only your own computers can lend to your Cloud. Connect this computer to your Cloud from its OMB Cloud settings." });
14293
+ sharedComputers.register(registration, { session: auth.session.id, person: CLOUD_HOME ? CLOUD_HOME_LENDER : personKey(auth.session) }, secret);
13199
14294
  return json(res, 200, { ok: true });
13200
14295
  }
13201
14296
  const route = /^\/api\/shared-computers\/([\w-]+)\/(poll|lease|result|disconnect)$/.exec(path);
@@ -13364,6 +14459,7 @@ const handleRequest = async (req, res) => {
13364
14459
  if (method === "POST" && requested !== "auto" && !parseSurface(requested)) {
13365
14460
  return json(res, 400, { error: "surface must be auto, cloud, vm, local, or browser" });
13366
14461
  }
14462
+ const unoffered = CLOUD_HOME && method === "POST" && requested !== "auto" ? cloudHomePlaceRefusal(parseSurface(requested)) : undefined;
13367
14463
  const options = await selectableComputers(bot);
13368
14464
  const current = source ? source.mounted ?? "off" : await computerPreviewSurface(bot, bot.threadId);
13369
14465
  requireActiveInternalCapability();
@@ -13371,6 +14467,8 @@ const handleRequest = async (req, res) => {
13371
14467
  return json(res, 200, { current, canSelect, options });
13372
14468
  if (computerSelectionTurns.get(internalCapability.threadId) !== source)
13373
14469
  return json(res, 409, { error: "The user request ended before its computer was selected." });
14470
+ if (unoffered)
14471
+ return json(res, 409, { error: unoffered, options });
13374
14472
  const option = requested === "auto"
13375
14473
  ? options.find(option => option.ready && option.surface === current) ?? options.find(option => option.ready && option.surface === "vm") ?? options.find(option => option.ready) ?? options.find(option => option.canStart) ?? options.find(option => option.canCreate && option.surface === "vm") ?? options.find(option => option.canCreate)
13376
14474
  : options.find(option => option.surface === requested);
@@ -13429,19 +14527,31 @@ const handleRequest = async (req, res) => {
13429
14527
  // Notes written from a room fewer people can see than this bot would
13430
14528
  // carry that room's words to everyone who can see the bot.
13431
14529
  if ((path === "/api/internal/memory" || path === "/api/internal/memory/log") && method === "POST") {
14530
+ // On a Cloud home a bot's memory loads into its owner's turns, and
14531
+ // through them can reach a lent Mac: only a conversation that is
14532
+ // provably the owner's may write it.
14533
+ if (CLOUD_HOME && cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) !== null) {
14534
+ return json(res, 403, { error: "Notes can only be saved from a conversation that only the owner of this Cloud has written in." });
14535
+ }
14536
+ if (internalSender.memoryEnabled === false) {
14537
+ return json(res, 403, { error: "Memory is disabled for this bot." });
14538
+ }
13432
14539
  const room = store.groupByThread(internalCapability.threadId);
13433
14540
  if (room && !roomFeedsBot(room, internalSender)) {
13434
14541
  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." });
13435
14542
  }
13436
14543
  }
14544
+ // On a Cloud home only the owner's conversations get here (above): the
14545
+ // write is theirs (server/lending-memory.ts).
14546
+ const ownersWrite = (write) => lendingMemory ? lendingMemory.trustedWrite(internalSender.id, write) : write();
13437
14547
  if (method === "POST" && path === "/api/internal/memory") {
13438
14548
  const body = await readInternalBody();
13439
- const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() });
14549
+ const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
13440
14550
  return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
13441
14551
  }
13442
14552
  if (method === "POST" && path === "/api/internal/memory/log") {
13443
14553
  const body = await readInternalBody();
13444
- const result = appendMemoryLog(internalSender.id, body.text, { source: memorySource() });
14554
+ const result = ownersWrite(() => appendMemoryLog(internalSender.id, body.text, { source: memorySource() }));
13445
14555
  return json(res, result.ok ? 200 : 400, result);
13446
14556
  }
13447
14557
  if (method === "POST" && path === "/api/internal/browser/mcp") {
@@ -13484,15 +14594,31 @@ const handleRequest = async (req, res) => {
13484
14594
  }
13485
14595
  // Off by default: both fall through to the same "unknown internal
13486
14596
  // endpoint" 404 a never-implemented route returns.
13487
- if (method === "GET" && path === "/api/internal/shared-computers" && sharedComputersEnabled(cfg))
13488
- return json(res, 200, { computers: sharedComputers.list() });
13489
- if (method === "POST" && path === "/api/internal/shared-computers" && sharedComputersEnabled(cfg)) {
14597
+ // Owner-scoped: a turn sees and uses only the computers of the person it
14598
+ // provably acts for (sharedComputerPrincipal), never anyone else's.
14599
+ if (method === "GET" && path === "/api/internal/shared-computers" && lendingEnabled()) {
14600
+ const principal = sharedComputerPrincipal(internalCapability);
14601
+ // Say why an owner's own conversation sees no Mac, so the bot can tell them.
14602
+ const why = CLOUD_HOME && !principal && sharedComputers.status(CLOUD_HOME_LENDER).length > 0 ? cloudLendingRefusal(internalCapability) : null;
14603
+ const notice = why === "someone-else" ? { unavailable: LENDING_SOMEONE_ELSE } : why === "memory-changed" ? { unavailable: LENDING_MEMORY_CHANGED } : {};
14604
+ return json(res, 200, { computers: sharedComputers.list(principal), ...notice });
14605
+ }
14606
+ if (method === "POST" && path === "/api/internal/shared-computers" && lendingEnabled()) {
13490
14607
  const parsed = sharedComputerOperation.safeParse(await readInternalBody());
13491
- if (!sharedComputersEnabled(cfg))
14608
+ if (!lendingEnabled())
13492
14609
  return json(res, 404, { error: "unknown internal endpoint" });
13493
14610
  if (!parsed.success)
13494
14611
  return json(res, 400, { error: "Invalid shared computer operation" });
13495
- return json(res, 200, { result: await sharedComputers.request(parsed.data, () => sharedComputersEnabled(cfg) && internalCapabilityIsActive(internalCapability)) });
14612
+ // The bot's own "no computer" setting holds on a lent screen too.
14613
+ if (["computer_tools", "computer_call"].includes(parsed.data.action) && store.bot(internalCapability.botId)?.computer === "off") {
14614
+ return json(res, 403, { error: "This bot has no computer. Change its Computer setting to use a shared computer's apps and screen." });
14615
+ }
14616
+ const principal = sharedComputerPrincipal(internalCapability);
14617
+ const why = CLOUD_HOME && !principal ? cloudLendingRefusal(internalCapability) : null;
14618
+ if (why === "someone-else" || why === "memory-changed") {
14619
+ return json(res, 403, { error: why === "someone-else" ? LENDING_SOMEONE_ELSE : LENDING_MEMORY_CHANGED });
14620
+ }
14621
+ return json(res, 200, { result: await sharedComputers.request(parsed.data, principal, () => lendingEnabled() && internalCapabilityIsActive(internalCapability)) });
13496
14622
  }
13497
14623
  if (method === "GET" && path === "/api/internal/agents") {
13498
14624
  const sender = internalSender;
@@ -13549,7 +14675,13 @@ const handleRequest = async (req, res) => {
13549
14675
  return "closed";
13550
14676
  return "idle";
13551
14677
  };
14678
+ // On a Cloud home a turn that may use the lent Mac sees only the
14679
+ // conversations the owner alone opened and wrote in: a title is words
14680
+ // too (cloudLendingView).
14681
+ const shown = cloudLendingView(internalCapability) ? cloudOwnerOnlyThread : () => true;
13552
14682
  for (const task of store.tasks(from.id)) {
14683
+ if (!shown(task.threadId))
14684
+ continue;
13553
14685
  rows.push({
13554
14686
  threadId: task.threadId, botId: from.id, botName: from.name, title: task.title,
13555
14687
  state: stateOf(from, task), unread: task.unread === true, openedAt: task.openedBy?.at ?? task.createdAt,
@@ -13558,7 +14690,7 @@ const handleRequest = async (req, res) => {
13558
14690
  }
13559
14691
  for (const peer of reachablePeers(store.bots, from)) {
13560
14692
  for (const task of store.tasks(peer.id)) {
13561
- if (task.openedBy?.botId !== from.id)
14693
+ if (task.openedBy?.botId !== from.id || !shown(task.threadId))
13562
14694
  continue;
13563
14695
  rows.push({
13564
14696
  threadId: task.threadId, botId: peer.id, botName: peer.name, title: task.title,
@@ -13791,6 +14923,12 @@ const handleRequest = async (req, res) => {
13791
14923
  : body.action === "update"
13792
14924
  ? { action: body.action, routineId: body.routineId, changes: body.changes, forBot }
13793
14925
  : { action: body.action, routineId: body.routineId, forBot };
14926
+ // Whether the routine a change is for is the owner's, and whether this
14927
+ // turn acts for the owner (their own conversation, or a run of a
14928
+ // routine that is theirs), both before the change.
14929
+ const wasOwners = proposedInput.action !== "create" && typeof proposedInput.routineId === "string" ? cloudRoutineIsOwners(proposedInput.routineId) : false;
14930
+ const run = activeRoutineRunForThread(fromThreadId);
14931
+ const ownersTurn = cloudOwnerOnlyThread(fromThreadId) || cloudRoutineRunIsOwners(run, from.id, fromThreadId);
13794
14932
  const proposed = await routineRequests.submit({
13795
14933
  botId: from.id,
13796
14934
  threadId: fromThreadId,
@@ -13799,6 +14937,17 @@ const handleRequest = async (req, res) => {
13799
14937
  canCommit: () => internalCapabilityIsActive(internalCapability),
13800
14938
  });
13801
14939
  const proposedCard = store.messagesFor(fromThreadId).find((message) => message.id === proposed.messageId)?.card;
14940
+ // Applied at once in a Full-access conversation. In the owner's own (one
14941
+ // only they provably wrote in) it is theirs, as if they had approved
14942
+ // the card. From anywhere else, a routine it created or changed is no
14943
+ // longer the owner's, like anyone else's edit (the routine PATCH).
14944
+ const applied = CLOUD_HOME && proposed.state === "applied" && "result" in proposed ? proposed.result : undefined;
14945
+ if (applied?.resultId && ownersTurn)
14946
+ cloudOwnerApplied(applied.action, applied.resultId, wasOwners);
14947
+ else if (applied?.resultId && (applied.action === "create" || applied.action === "update")) {
14948
+ cloudRoutineAuthors?.forget(applied.resultId);
14949
+ cloudRoutineAuthors?.wrote(applied.resultId, CLOUD_NOBODY_KEY);
14950
+ }
13802
14951
  appendDecision(DATA_DIR, {
13803
14952
  threadId: fromThreadId,
13804
14953
  requestId: proposed.requestId,
@@ -14017,7 +15166,7 @@ const handleRequest = async (req, res) => {
14017
15166
  if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
14018
15167
  return json(res, 400, { error: "scope must be all, conversations, or memory" });
14019
15168
  }
14020
- const memoryHits = scope === "conversations" || !q ? [] : searchMemoryFiles(from.id, q, limit);
15169
+ const memoryHits = scope === "conversations" || !q || from.memoryEnabled === false ? [] : searchMemoryFiles(from.id, q, limit);
14021
15170
  if (scope === "memory")
14022
15171
  return json(res, 200, { hits: [], memoryHits });
14023
15172
  // Own threads: the bot's main chat and tasks, and the rooms it is a
@@ -14033,7 +15182,10 @@ const handleRequest = async (req, res) => {
14033
15182
  for (const task of group.tasks ?? [])
14034
15183
  roomByThread.set(task.threadId, group);
14035
15184
  }
14036
- const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])];
15185
+ const ownThreads = [...new Set([from.threadId, ...(from.tasks ?? []).map((task) => task.threadId), ...roomByThread.keys()])]
15186
+ // On a Cloud home a turn that may use the lent Mac searches only the
15187
+ // conversations the owner alone opened and wrote in.
15188
+ .filter(cloudLendingView(internalCapability) ? cloudOwnerOnlyThread : () => true);
14037
15189
  // A room is the only place a recall can be a disclosure, and only a
14038
15190
  // private chat is one: in a 1:1 the user already owns every thread
14039
15191
  // the bot can reach, and a room's lines were said in the open.
@@ -14072,7 +15224,8 @@ const handleRequest = async (req, res) => {
14072
15224
  const messageId = String(url.searchParams.get("messageId") ?? "").trim();
14073
15225
  if (!threadId || !messageId)
14074
15226
  return json(res, 400, { error: "threadId and messageId are required" });
14075
- const own = threadId === from.threadId || Boolean(store.taskByThread(from.id, threadId));
15227
+ const own = (threadId === from.threadId || Boolean(store.taskByThread(from.id, threadId))) &&
15228
+ (!cloudLendingView(internalCapability) || cloudOwnerOnlyThread(threadId));
14076
15229
  const message = own ? readMessageText(threadId, messageId) : null;
14077
15230
  if (!message)
14078
15231
  return json(res, 404, { error: "no such message in your conversations" });
@@ -14242,7 +15395,12 @@ const handleRequest = async (req, res) => {
14242
15395
  // can check next turn. If the ledger refuses (cap/depth), fall back
14243
15396
  // to the plain busy bounce rather than dropping the refusal reason.
14244
15397
  const queueBusyFallback = (approvalAlreadyGranted = false) => {
14245
- const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted }, MAX_COMMS_DEPTH, fromThreadId);
15398
+ const guestThread = guestWorkThread(from, target, fromThreadId, message);
15399
+ if (guestThread === null)
15400
+ return json(res, 500, { error: "couldn't open a conversation for that work" });
15401
+ const queued = queueDelegation(commsBus, from, { toBotId, message, reason: "asked while busy", depth, approvalAlreadyGranted, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
15402
+ if ((queued.result !== "ok" || !queued.id) && guestThread)
15403
+ store.deleteTask(toBotId, guestThread);
14246
15404
  if (queued.result !== "ok" || !queued.id)
14247
15405
  return json(res, 200, { busy: true, receipt: peerDeliveryReceipt({
14248
15406
  botId: toBotId, botName: target.name, outcome: "failed",
@@ -14270,6 +15428,10 @@ const handleRequest = async (req, res) => {
14270
15428
  const tryAsideDelivery = async () => {
14271
15429
  if (peerReviewRequired(from, fromThreadId))
14272
15430
  return null;
15431
+ // A guest's request never folds into a turn running in the
15432
+ // owner's conversation; it waits for a confined one of its own.
15433
+ if (cloudGuestDriven(fromThreadId))
15434
+ return null;
14273
15435
  // Busy elsewhere (room turn, sibling thread): the thread this ask
14274
15436
  // targets is not the running one, so there is nothing to fold
14275
15437
  // into here — the queue lane keeps it.
@@ -14376,7 +15538,10 @@ const handleRequest = async (req, res) => {
14376
15538
  delivery: "ask_bot",
14377
15539
  unattended: isUnattended(currentFrom.id, fromThreadId),
14378
15540
  });
14379
- const targetThreadId = currentTarget.threadId;
15541
+ const guestThread = guestWorkThread(currentFrom, currentTarget, fromThreadId, message);
15542
+ if (guestThread === null)
15543
+ return json(res, 500, { error: "couldn't open a conversation for that work" });
15544
+ const targetThreadId = guestThread ?? currentTarget.threadId;
14380
15545
  const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId, fromThreadId, targetThreadId);
14381
15546
  requireActiveInternalCapability();
14382
15547
  const replySender = store.bot(fromBotId);
@@ -14588,7 +15753,12 @@ const handleRequest = async (req, res) => {
14588
15753
  if (!connectorThread(from.id, fromThreadId)) {
14589
15754
  return json(res, 403, { error: "source thread does not belong to sender" });
14590
15755
  }
14591
- const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth }, MAX_COMMS_DEPTH, fromThreadId);
15756
+ const guestThread = guestWorkThread(from, target, fromThreadId, reason ?? message);
15757
+ if (guestThread === null)
15758
+ return json(res, 500, { error: "couldn't open a conversation for that work" });
15759
+ const queued = queueDelegation(commsBus, from, { toBotId, message, reason, depth, ...(guestThread ? { targetThreadId: guestThread } : {}) }, MAX_COMMS_DEPTH, fromThreadId);
15760
+ if ((queued.result !== "ok" || !queued.id) && guestThread)
15761
+ store.deleteTask(toBotId, guestThread);
14592
15762
  if (queued.result !== "ok" || !queued.id) {
14593
15763
  // the agent reads this string — a bare enum ("too_deep") tells it
14594
15764
  // nothing about what to do instead
@@ -14656,7 +15826,7 @@ const handleRequest = async (req, res) => {
14656
15826
  })).filter(g => g.members.length);
14657
15827
  return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
14658
15828
  bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
14659
- rooms, note: "Without group_id: use this room when in a room, otherwise your standing conversation with that teammate — every assignment you send it continues the same thread, so write as if it remembers the last one. 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." });
15829
+ 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." });
14660
15830
  }
14661
15831
  if (method === "POST" && path === "/api/internal/coordinate-bots") {
14662
15832
  const parsed = z.object({
@@ -14733,39 +15903,21 @@ const handleRequest = async (req, res) => {
14733
15903
  try {
14734
15904
  requireActiveInternalCapability();
14735
15905
  if (!destination) {
14736
- // One durable conversation per pair of bots, resolved from
14737
- // the recipient's own threads — never from this turn, the
14738
- // request key, or the thread the person has selected there.
14739
- const resolved = store.resolvePairConversation(internalSender, target.botId, {
14740
- label: parsed.data.label,
14741
- // "Still working" exactly as close_thread reads it: a
14742
- // running turn, a queued one, or coordinated work already
14743
- // addressed at that thread.
14744
- working: threadId => threadBusy(target.botId, threadId)
14745
- || queuedThreadPosition(target.botId, threadId) !== null
14746
- || roomHandoffs.activeDirect(threadId),
14747
- });
14748
- if (!resolved)
15906
+ 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() });
15907
+ if (!task)
14749
15908
  throw new Error("The recipient no longer exists");
14750
- target.threadId = resolved.task.threadId;
14751
- if (resolved.created)
14752
- createdThread = resolved.task.threadId;
14753
- // A work thread carries one assignment: it is for the person
14754
- // this coordination serves. The durable pair conversation is
14755
- // shared by every assignment between two bots, so it names nobody.
14756
- if (resolved.created && resolved.task.openedBy?.kind === "work")
14757
- threadStarters.set(resolved.task.threadId, threadPersonKey(address.threadId));
15909
+ target.threadId = createdThread = task.threadId;
14758
15910
  if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
14759
15911
  grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
14760
15912
  }
14761
15913
  }
14762
15914
  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);
14763
- // A re-dispatched request_key is answered by the request it
14764
- // already made, so a thread resolved for the retry (the pair
14765
- // conversation was busy with that very request) goes back
14766
- // before anyone sees a row that leads nowhere.
14767
- if (duplicate && createdThread && createdThread !== node.threadId)
15915
+ // A retry keeps its original task; discard the speculative row.
15916
+ if (duplicate && createdThread && createdThread !== node.threadId) {
14768
15917
  store.deleteTask(target.botId, createdThread);
15918
+ }
15919
+ if (!duplicate && createdThread)
15920
+ threadStarters.set(createdThread, openerFrom(address.threadId));
14769
15921
  createdThread = undefined; // The durable coordinator now owns this task.
14770
15922
  accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
14771
15923
  // A duplicate request_key lands on the node enqueue already
@@ -14805,8 +15957,9 @@ const handleRequest = async (req, res) => {
14805
15957
  }
14806
15958
  }
14807
15959
  catch (error) {
14808
- if (createdThread)
15960
+ if (createdThread) {
14809
15961
  store.deleteTask(target.botId, createdThread);
15962
+ }
14810
15963
  const said = error instanceof Error ? error.message : String(error);
14811
15964
  errors.push({ botId: target.botId, error: said });
14812
15965
  receipts.push(peerDeliveryReceipt({
@@ -15049,10 +16202,11 @@ const handleRequest = async (req, res) => {
15049
16202
  }
15050
16203
  const sourceTitle = owner.group ? owner.group.name : (store.taskByThread(from.id, fromThreadId)?.title ?? "");
15051
16204
  if (target.id === from.id) {
15052
- const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() });
16205
+ const inherited = parentThreadModel(from.id, fromThreadId);
16206
+ const task = store.createTask(from.id, title, false, projectId, { botId: from.id, name: from.name, at: Date.now() }, inherited.approvalMode, inherited.modelSelection);
15053
16207
  if (!task)
15054
16208
  return json(res, 500, { error: "couldn't create that thread" });
15055
- threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
16209
+ threadStarters.set(task.threadId, openerFrom(fromThreadId));
15056
16210
  internalCapability.openedThreads += 1;
15057
16211
  const chip = {
15058
16212
  role: "bot",
@@ -15095,7 +16249,7 @@ const handleRequest = async (req, res) => {
15095
16249
  if (!task)
15096
16250
  return json(res, 500, { error: "couldn't create that thread" });
15097
16251
  // The work is still for the person whose request the opener is on.
15098
- threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
16252
+ threadStarters.set(task.threadId, openerFrom(fromThreadId));
15099
16253
  if (delegatedFullAccess(from, fromThreadId, target))
15100
16254
  grantDelegatedFullAccess(from, target, task.threadId);
15101
16255
  const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
@@ -15266,7 +16420,11 @@ const handleRequest = async (req, res) => {
15266
16420
  }
15267
16421
  const group = createChannel({
15268
16422
  name: redactSecretsInText(parsed.data.name), memberIds, section: chief.section,
15269
- setup: { bulletin: redactSecretsInText(parsed.data.bulletin ?? ""), defaultResponder: { kind: "member", botId: chief.id } },
16423
+ setup: {
16424
+ bulletin: redactSecretsInText(parsed.data.bulletin ?? ""),
16425
+ // The Chief leads; on Auto it is the fallback when the decision model is unsure.
16426
+ defaultResponder: deciderReady(cfg, "roomRouting") ? { kind: "auto", fallbackBotId: chief.id } : { kind: "member", botId: chief.id },
16427
+ },
15270
16428
  });
15271
16429
  internalCapability.createdRooms = (internalCapability.createdRooms ?? 0) + 1;
15272
16430
  return json(res, 201, { id: group.id, name: group.name, section: group.section || "General", memberIds: group.memberIds, memberCount: group.memberIds.length });
@@ -15640,12 +16798,26 @@ const handleRequest = async (req, res) => {
15640
16798
  .filter((run) => routineVisible(run, visible)),
15641
16799
  });
15642
16800
  }
16801
+ // On a Cloud home a routine reports only where its writer may write: a
16802
+ // guest's into a conversation the guest started, never the owner's.
16803
+ const routineResultsRefusal = (body) => body && typeof body === "object" && typeof body.resultsThreadId === "string"
16804
+ ? cloudThreadRefusal(auth, body.resultsThreadId) : null;
15643
16805
  if (path === "/api/routines" && method === "POST") {
15644
16806
  const body = await readBody(req);
15645
16807
  const hidden = hiddenRoutineTarget(body, visible);
15646
16808
  if (hidden)
15647
16809
  return json(res, 404, { error: hidden });
15648
- return json(res, 201, { routine: routines.create(body) });
16810
+ const notYours = routineResultsRefusal(body);
16811
+ if (notYours)
16812
+ return json(res, 403, { error: notYours });
16813
+ const writer = auth.kind === "session" ? actorKey(auth) : CLOUD_NOBODY_KEY;
16814
+ const routine = withRoutineWriter(writer, () => routines.create(body));
16815
+ // On a Cloud home, a routine the owner writes from their own device may
16816
+ // use their lent Mac when it runs (server/cloud-lending.ts).
16817
+ if (cloudRoutineAuthors && cloudOwnerSession(auth))
16818
+ cloudRoutineAuthors.record(routine.id, routine);
16819
+ cloudRoutineAuthors?.wrote(routine.id, writer);
16820
+ return json(res, 201, { routine });
15649
16821
  }
15650
16822
  // The desktop shell polls this to decide whether to hold the computer
15651
16823
  // awake: a run in flight, or a routine due within the hour.
@@ -15655,6 +16827,8 @@ const handleRequest = async (req, res) => {
15655
16827
  let routineMatch = path.match(/^\/api\/routines\/([\w-]+)\/run$/);
15656
16828
  if (routineMatch && method === "POST") {
15657
16829
  const run = routines.runNow(routineMatch[1]);
16830
+ if (run && CLOUD_HOME && cloudOwnerSession(auth))
16831
+ ownerStartedRoutineRuns.add(run.id);
15658
16832
  return run ? json(res, 201, { run }) : json(res, 404, { error: "no such routine" });
15659
16833
  }
15660
16834
  routineMatch = path.match(/^\/api\/routines\/([\w-]+)$/);
@@ -15663,11 +16837,45 @@ const handleRequest = async (req, res) => {
15663
16837
  const hidden = hiddenRoutineTarget(body, visible);
15664
16838
  if (hidden)
15665
16839
  return json(res, 404, { error: hidden });
15666
- const routine = routines.update(routineMatch[1], body);
16840
+ const notYours = routineResultsRefusal(body);
16841
+ if (notYours)
16842
+ return json(res, 403, { error: notYours });
16843
+ const before = cloudRoutineAuthors ? routines.listRoutines().find((candidate) => candidate.id === routineMatch[1]) : undefined;
16844
+ const wasOwners = Boolean(before && cloudRoutineAuthors?.authored(before.id, before));
16845
+ const previous = cloudRoutineAuthors?.writer(routineMatch[1]);
16846
+ // Who this edit leaves as the routine's writer: the owner when the
16847
+ // routine stays (or becomes) theirs (their fingerprint, or already
16848
+ // theirs as its writer: a template, a restore), a guest when a guest
16849
+ // edits it, else whoever wrote it before.
16850
+ // An owner writer is proof only for a routine that never had the
16851
+ // owner's fingerprint (a template, a restore, one the settlement named):
16852
+ // a fingerprint that no longer matches means someone else changed what
16853
+ // it runs, and only the owner rewriting its instructions renews it.
16854
+ const ownerWriterOnly = CLOUD_OWNER_KEY !== null && previous === CLOUD_OWNER_KEY && !cloudRoutineAuthors?.recorded(routineMatch[1]);
16855
+ const ownerAuthors = cloudOwnerSession(auth) && (wasOwners || ownerWriterOnly ||
16856
+ (body && typeof body === "object" && "prompt" in body));
16857
+ const writer = ownerAuthors && CLOUD_OWNER_KEY ? CLOUD_OWNER_KEY
16858
+ : auth.kind === "session" && !cloudOwnerSession(auth) ? actorKey(auth)
16859
+ : previous && !cloudOwnerPerson(previous) ? previous : CLOUD_NOBODY_KEY;
16860
+ const routine = withRoutineWriter(writer, () => routines.update(routineMatch[1], body));
16861
+ // The owner's own edit keeps (or, when it rewrites the instructions,
16862
+ // makes) the routine theirs. Anyone else's edit, of any field (its
16863
+ // schedule, where its results go, whether it is on), makes it no longer
16864
+ // the owner's: it stops lending until the owner rewrites it.
16865
+ if (routine && cloudRoutineAuthors) {
16866
+ if (!cloudOwnerSession(auth))
16867
+ cloudRoutineAuthors.forget(routine.id);
16868
+ else if (ownerAuthors)
16869
+ cloudRoutineAuthors.record(routine.id, routine);
16870
+ cloudRoutineAuthors.wrote(routine.id, writer);
16871
+ }
15667
16872
  return routine ? json(res, 200, { routine }) : json(res, 404, { error: "no such routine" });
15668
16873
  }
15669
16874
  if (routineMatch && method === "DELETE") {
15670
- return routines.remove(routineMatch[1])
16875
+ const removed = routines.remove(routineMatch[1]);
16876
+ if (removed)
16877
+ cloudRoutineAuthors?.remove(routineMatch[1]);
16878
+ return removed
15671
16879
  ? json(res, 200, { ok: true })
15672
16880
  : json(res, 404, { error: "no such routine" });
15673
16881
  }
@@ -16340,7 +17548,7 @@ const handleRequest = async (req, res) => {
16340
17548
  const current = resolveRequestAuth(req, {
16341
17549
  sessions, cookieName: SESSION_COOKIE, streamPath: "/api/events", url,
16342
17550
  loopbackMutationToken: desktopMutationToken, companionMutationToken,
16343
- features: { sharedComputers: sharedComputersEnabled(cfg) }, loopbackTrust: LOOPBACK.trust, cliOwnerToken,
17551
+ features: { sharedComputers: lendingEnabled() }, loopbackTrust: LOOPBACK.trust, cliOwnerToken,
16344
17552
  });
16345
17553
  if (!current.auth)
16346
17554
  return json(res, current.status, { error: current.error });
@@ -16428,6 +17636,9 @@ const handleRequest = async (req, res) => {
16428
17636
  if (hiddenMember)
16429
17637
  return json(res, 400, { error: `unknown channel member: ${String(hiddenMember)}` });
16430
17638
  const group = createChannel(body);
17639
+ // On a Cloud home whoever opens a room names it (cloudThreadRefusal).
17640
+ if (CLOUD_HOME && auth.kind === "session")
17641
+ threadStarters.set(group.threadId, actorKey(auth));
16431
17642
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
16432
17643
  }
16433
17644
  if (method === "POST" && path === "/api/teams/export") {
@@ -16838,15 +18049,7 @@ const handleRequest = async (req, res) => {
16838
18049
  return json(res, 400, { error: "bulletin must be a string" });
16839
18050
  if (body.bulletin.length > 12_000)
16840
18051
  return json(res, 400, { error: "bulletin must be at most 12000 characters" });
16841
- const value = body.defaultResponder;
16842
- let responder = null;
16843
- if (value?.kind === "everyone")
16844
- responder = { kind: "everyone" };
16845
- else if (value?.kind === "mentions")
16846
- responder = { kind: "mentions" };
16847
- else if (value?.kind === "member" && typeof value.botId === "string" && group.memberIds.includes(value.botId)) {
16848
- responder = { kind: "member", botId: value.botId };
16849
- }
18052
+ const responder = checkedGroupResponder(body.defaultResponder, group.memberIds);
16850
18053
  if (!responder)
16851
18054
  return json(res, 400, { error: "invalid default responder" });
16852
18055
  patch.cwd = checked.cwd ?? undefined;
@@ -16903,7 +18106,7 @@ const handleRequest = async (req, res) => {
16903
18106
  return json(res, 500, { error: "couldn't create that task" });
16904
18107
  // Who opened it decides who may answer its cards on a shared workspace.
16905
18108
  if (auth.kind === "session")
16906
- threadStarters.set(task.threadId, personKey(auth.session));
18109
+ threadStarters.set(task.threadId, actorKey(auth));
16907
18110
  const fresh = groupWithThread(store.group(group.id));
16908
18111
  broadcast({ kind: "group", group: fresh });
16909
18112
  return json(res, 201, { group: fresh, task });
@@ -16980,6 +18183,9 @@ const handleRequest = async (req, res) => {
16980
18183
  // without the old handler turning a missing title into "Untitled".
16981
18184
  // That echo is not a rename. A real rename stays blocked while working.
16982
18185
  const titleChange = renaming && body.title !== existing.title;
18186
+ const notYours = titleChange ? cloudThreadRefusal(auth, m[2]) : null;
18187
+ if (notYours)
18188
+ return json(res, 403, { error: notYours });
16983
18189
  if (channelTaskBlocked(group) && !(pinning && !titleChange)) {
16984
18190
  return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
16985
18191
  }
@@ -17012,6 +18218,9 @@ const handleRequest = async (req, res) => {
17012
18218
  }
17013
18219
  if (!store.groupTaskByThread(group.id, m[2]))
17014
18220
  return json(res, 404, { error: "no such channel task" });
18221
+ const notYours = cloudThreadRefusal(auth, m[2]);
18222
+ if (notYours)
18223
+ return json(res, 403, { error: notYours });
17015
18224
  const stagedSkillCleanups = stagedSkillCleanupsForThread(m[2]);
17016
18225
  lastReply.delete(m[2]);
17017
18226
  cancelTeamSetupResumesForThread(m[2]);
@@ -17033,6 +18242,14 @@ const handleRequest = async (req, res) => {
17033
18242
  if (field)
17034
18243
  return json(res, 403, { error: `forbidden: this session may rename or mark a room, not change "${field}" (needs the admin scope)` });
17035
18244
  }
18245
+ // On a Cloud home a room's name and bulletin are its opener's (or the owner's).
18246
+ const renamed = store.group(m[1]);
18247
+ if (renamed && body && typeof body === "object" &&
18248
+ ((body.name !== undefined && body.name !== renamed.name) || (body.bulletin !== undefined && body.bulletin !== (renamed.bulletin ?? "")))) {
18249
+ const notYours = cloudThreadRefusal(auth, renamed.threadId);
18250
+ if (notYours)
18251
+ return json(res, 403, { error: notYours });
18252
+ }
17036
18253
  const group = updateChannel(m[1], body);
17037
18254
  return json(res, 200, { group: publicGroupState(group) });
17038
18255
  }
@@ -17657,9 +18874,14 @@ const handleRequest = async (req, res) => {
17657
18874
  throw Object.assign(new Error(enabled.error), { status: 422 });
17658
18875
  }
17659
18876
  }
18877
+ // On a Cloud home only the owner's devices create bots: its routines
18878
+ // (and the conversations they report into) are theirs.
18879
+ const ownersRoutines = CLOUD_OWNER_KEY && auth.kind === "session" && cloudOwnerSession(auth) ? CLOUD_OWNER_KEY : null;
17660
18880
  for (const routine of template.routines) {
17661
18881
  const created = routines.create({ ...routine, botId: bot.id, enabled: false });
17662
18882
  createdRoutines.push({ id: created.id, enabled: routine.enabled !== false });
18883
+ if (ownersRoutines)
18884
+ cloudRoutineAuthors?.wrote(created.id, ownersRoutines);
17663
18885
  }
17664
18886
  }
17665
18887
  catch (error) {
@@ -17745,6 +18967,17 @@ const handleRequest = async (req, res) => {
17745
18967
  return json(res, 400, { error: "avatarUrl must reference an existing stored image" });
17746
18968
  }
17747
18969
  const existingBot = store.bot(m[1]);
18970
+ // On a Cloud home a bot's name, title, description and standing
18971
+ // instructions ride every one of the owner's turns (and a lent Mac):
18972
+ // only the owner's own devices change them. Anyone else keeps the
18973
+ // picture and the voice. Resending an unchanged value is not a change.
18974
+ if (CLOUD_HOME && existingBot && !cloudOwnerSession(auth)) {
18975
+ const changed = CLOUD_OWNER_PROFILE_FIELDS.find((field) => parsed.patch[field] !== undefined && (field === "notifications"
18976
+ ? (parsed.patch.notifications !== false) !== (existingBot.notifications !== false)
18977
+ : parsed.patch[field] !== (existingBot[field] ?? "")));
18978
+ if (changed)
18979
+ return json(res, 403, { error: "On this Cloud only its owner can change a bot's name, title, description, instructions or notifications." });
18980
+ }
17748
18981
  const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
17749
18982
  const bot = store.patchBotProfile(m[1], parsed.patch);
17750
18983
  if (!bot)
@@ -17998,6 +19231,15 @@ const handleRequest = async (req, res) => {
17998
19231
  return json(res, 400, { error: "voiceNotes must be true or false" });
17999
19232
  patch.voiceNotes = body.voiceNotes;
18000
19233
  }
19234
+ if (body.memoryEnabled !== undefined) {
19235
+ if (typeof body.memoryEnabled !== "boolean")
19236
+ return json(res, 400, { error: "memoryEnabled must be true or false" });
19237
+ if (existingBot && (existingBot.busy || activeGroupTurnForBot(existingBot.id)) &&
19238
+ body.memoryEnabled !== (existingBot.memoryEnabled !== false)) {
19239
+ return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
19240
+ }
19241
+ patch.memoryEnabled = body.memoryEnabled;
19242
+ }
18001
19243
  // which of those apps' tools this bot may call (connector grants 1/5:
18002
19244
  // data model only — enforcement lands with slice 2). null returns the
18003
19245
  // bot to boolean-only legacy behavior; {} means no tools.
@@ -18334,6 +19576,13 @@ const handleRequest = async (req, res) => {
18334
19576
  sectionKey(existingBot?.section) !== sectionKey(section);
18335
19577
  let bot;
18336
19578
  const freshBrowserBot = store.bot(m[1]);
19579
+ // Connector validation and runtime revocation can yield after the first
19580
+ // memory check. Keep the same idle-only transition at the final commit.
19581
+ if (freshBrowserBot && body.memoryEnabled !== undefined &&
19582
+ body.memoryEnabled !== (freshBrowserBot.memoryEnabled !== false) &&
19583
+ (freshBrowserBot.busy || activeGroupTurnForBot(freshBrowserBot.id))) {
19584
+ return json(res, 409, { error: "stop this bot's turn before changing its memory setting" });
19585
+ }
18337
19586
  // Custom servers are process configuration: a running turn cannot
18338
19587
  // unmount them. Recheck after any awaited runtime revocation above.
18339
19588
  if (body.mcpServers !== undefined) {
@@ -18389,7 +19638,7 @@ const handleRequest = async (req, res) => {
18389
19638
  }
18390
19639
  if (!bot)
18391
19640
  return json(res, 404, { error: "no such bot" });
18392
- if (body.memoryUpkeep === false)
19641
+ if (body.memoryUpkeep === false || body.memoryEnabled === false)
18393
19642
  memoryUpkeep.dropBot(bot.id);
18394
19643
  // A defined Works on is the newest explicit choice: this bot's
18395
19644
  // auto-recorded pins that now point elsewhere give way immediately, so
@@ -18775,6 +20024,12 @@ const handleRequest = async (req, res) => {
18775
20024
  if (existing.card.requestId) {
18776
20025
  return json(res, 409, { error: "request cards must be answered through the approval endpoint" });
18777
20026
  }
20027
+ // On a Cloud home only the owner answers cards (cardAnswerRefusal).
20028
+ // This route only keeps how an options card looks; the answer itself
20029
+ // reaches the bot as a chat line, which lending already checks.
20030
+ const cloudRefusal = CLOUD_HOME && body.answered !== undefined && auth.kind === "session" ? cloudCardRefusal(auth) : null;
20031
+ if (cloudRefusal)
20032
+ return json(res, 403, { error: cloudRefusal });
18778
20033
  if (Object.keys(body).some((key) => key !== "answered" && key !== "dismissed" && key !== "threadId")) {
18779
20034
  return json(res, 400, { error: "only answered and dismissed may be changed" });
18780
20035
  }
@@ -18789,6 +20044,7 @@ const handleRequest = async (req, res) => {
18789
20044
  ...existing.card,
18790
20045
  ...(body.answered !== undefined ? { answered: body.answered } : {}),
18791
20046
  ...(body.dismissed !== undefined ? { dismissed: body.dismissed } : {}),
20047
+ ...(CLOUD_HOME && body.answered !== undefined ? { answeredBy: cardAnswererFor(auth) } : {}),
18792
20048
  },
18793
20049
  });
18794
20050
  return json(res, 200, { message: patched });
@@ -18852,6 +20108,9 @@ const handleRequest = async (req, res) => {
18852
20108
  // receipt after a task switch, while a genuinely new send still has to
18853
20109
  // target the task that is active now.
18854
20110
  const threadId = body.threadId ?? bot.threadId;
20111
+ const notYours = cloudGuestSendRefusal(auth, threadId);
20112
+ if (notYours)
20113
+ return json(res, 403, { error: notYours });
18855
20114
  // Who this message is from, for the ledger. It is captured here and
18856
20115
  // travels with the message: into the turn it starts, or into the queue
18857
20116
  // until it drains. A message steered into someone else's running turn
@@ -19112,6 +20371,9 @@ const handleRequest = async (req, res) => {
19112
20371
  const body = await readBody(req);
19113
20372
  requirePinnedClientThread(m[1], body?.threadId);
19114
20373
  const bot = requestedTaskBot(m[1], body?.threadId);
20374
+ const notYours = cloudThreadRefusal(auth, bot.threadId);
20375
+ if (notYours)
20376
+ return json(res, 403, { error: notYours });
19115
20377
  await startTurn(bot.id, "Summarize conversation context", {
19116
20378
  threadId: bot.threadId, compactOnly: true, cardContinuation: true, trigger: usageTriggerFor(auth),
19117
20379
  });
@@ -19127,6 +20389,9 @@ const handleRequest = async (req, res) => {
19127
20389
  const body = await readBody(req);
19128
20390
  requirePinnedClientThread(m[1], body?.threadId);
19129
20391
  const bot = requestedTaskBot(m[1], body.threadId);
20392
+ const notYours = cloudThreadRefusal(auth, bot.threadId);
20393
+ if (notYours)
20394
+ return json(res, 403, { error: notYours });
19130
20395
  const text = String(body.text ?? "").trim();
19131
20396
  if (!text)
19132
20397
  return json(res, 400, { error: "text required" });
@@ -19167,7 +20432,10 @@ const handleRequest = async (req, res) => {
19167
20432
  // startTurn admits the rerun before branching. A shared-resource or
19168
20433
  // concurrency-limit refusal must leave the original transcript intact.
19169
20434
  const replyTo = source.replyToId ? resolveReplyTarget(bot.threadId, source.replyToId) : undefined;
19170
- const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo, sendId, trigger: usageTriggerFor(auth) });
20435
+ // On a Cloud home an edit is its author's line, so the owner's own edit
20436
+ // keeps their conversation theirs for lending (server/cloud-lending.ts).
20437
+ const message = await startTurn(bot.id, text, { threadId: bot.threadId, editedMessageId: messageId, replyTo, sendId, trigger: usageTriggerFor(auth),
20438
+ ...(CLOUD_HOME ? { sender: messageSender(auth) } : {}) });
19171
20439
  return json(res, 202, { ok: true, message });
19172
20440
  }
19173
20441
  // switch which fork of the conversation is visible (no new turn)
@@ -19176,6 +20444,9 @@ const handleRequest = async (req, res) => {
19176
20444
  const body = await readBody(req);
19177
20445
  requirePinnedClientThread(m[1], body?.threadId);
19178
20446
  const bot = requestedTaskBot(m[1], body.threadId);
20447
+ const notYours = cloudThreadRefusal(auth, bot.threadId);
20448
+ if (notYours)
20449
+ return json(res, 403, { error: notYours });
19179
20450
  if (threadBusy(bot.id, bot.threadId))
19180
20451
  return json(res, 409, { error: "the thread is working — stop it before switching versions" });
19181
20452
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
@@ -19216,7 +20487,7 @@ const handleRequest = async (req, res) => {
19216
20487
  threadId: bot.threadId,
19217
20488
  requestId: String(body.requestId),
19218
20489
  behavior,
19219
- }))
20490
+ }, auth.kind === "session" && cloudOwnerSession(auth)))
19220
20491
  return;
19221
20492
  if (resolveAndSendProfile(res, {
19222
20493
  botId: bot.id,
@@ -19314,7 +20585,7 @@ const handleRequest = async (req, res) => {
19314
20585
  threadId,
19315
20586
  requestId,
19316
20587
  behavior,
19317
- }))
20588
+ }, auth.kind === "session" && cloudOwnerSession(auth)))
19318
20589
  return;
19319
20590
  }
19320
20591
  const setupCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamSetupRequest);
@@ -19553,7 +20824,7 @@ const handleRequest = async (req, res) => {
19553
20824
  return json(res, 500, { error: "couldn't create that task" });
19554
20825
  // Who opened it decides who may answer its cards on a shared workspace.
19555
20826
  if (auth.kind === "session")
19556
- threadStarters.set(task.threadId, personKey(auth.session));
20827
+ threadStarters.set(task.threadId, actorKey(auth));
19557
20828
  const fresh = botWithThread(store.bot(bot.id));
19558
20829
  broadcast({ kind: "bot", bot: fresh });
19559
20830
  return json(res, 201, { bot: fresh, task: wireTask(task) });
@@ -19599,6 +20870,16 @@ const handleRequest = async (req, res) => {
19599
20870
  const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface"]);
19600
20871
  if (Object.keys(body).some((key) => !allowed.has(key)))
19601
20872
  return json(res, 400, { error: "unsupported thread setting" });
20873
+ const notYours = cloudThreadRefusal(auth, m[2]);
20874
+ if (notYours)
20875
+ return json(res, 403, { error: notYours });
20876
+ // On a Cloud home only the owner decides whether a bot may act without
20877
+ // asking them, even in a conversation someone else started, and what
20878
+ // the bot itself defaults to.
20879
+ if (CLOUD_HOME && !cloudOwnerSession(auth) &&
20880
+ (body.approvalMode !== undefined || body.autoApprove !== undefined || body.acknowledgeLocalAuto !== undefined || body.updateBotDefault === true)) {
20881
+ return json(res, 403, { error: "On this Cloud only its owner can change how a bot asks for approval, or its default model." });
20882
+ }
19602
20883
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
19603
20884
  if (body[key] !== undefined && typeof body[key] !== "boolean")
19604
20885
  return json(res, 400, { error: `${key} must be a boolean` });
@@ -19724,6 +21005,9 @@ const handleRequest = async (req, res) => {
19724
21005
  if (!bot || !store.taskByThread(bot.id, m[2])) {
19725
21006
  return json(res, 404, { error: "no such task" });
19726
21007
  }
21008
+ const notYours = cloudThreadRefusal(auth, m[2]);
21009
+ if (notYours)
21010
+ return json(res, 403, { error: notYours });
19727
21011
  if (phoneSecretSubmissions.hasThread(m[2])) {
19728
21012
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
19729
21013
  }
@@ -19744,6 +21028,55 @@ const handleRequest = async (req, res) => {
19744
21028
  broadcast({ kind: "bot", bot: fresh });
19745
21029
  return json(res, 200, { bot: fresh });
19746
21030
  }
21031
+ // Regenerate title (#1858): name the thread again from its conversation
21032
+ // as it is now. The person asked, so the result replaces any title, but
21033
+ // only the one this started from: a rename that lands while the one-shot
21034
+ // runs wins, and the late result is dropped.
21035
+ m = path.match(/^\/api\/bots\/([\w-]+)\/tasks\/([\w-]+)\/title$/);
21036
+ if (m && method === "POST") {
21037
+ const current = store.projectBotForTask(m[1], m[2]);
21038
+ if (!current)
21039
+ return json(res, 404, { error: "no such task" });
21040
+ const notYours = cloudThreadRefusal(auth, m[2]);
21041
+ if (notYours)
21042
+ return json(res, 403, { error: notYours });
21043
+ if (!llmThreadTitlesEnabled(cfg))
21044
+ return json(res, 409, { error: "generated thread titles are turned off on this server" });
21045
+ if (hostedModels && !hostedModels.allows(current.modelSelection))
21046
+ return json(res, 409, { error: hostedModels.error() });
21047
+ const instance = registry.get(current.modelSelection.instanceId);
21048
+ if (!instance?.generateText)
21049
+ return json(res, 409, { error: "this bot's model can't generate a title — rename the thread instead" });
21050
+ // An engine the organisation disallows never receives the text, even for a title.
21051
+ const refusal = policyModelRefusal(instance);
21052
+ if (refusal)
21053
+ return json(res, 409, { error: refusal });
21054
+ if (titleRegenerations.has(m[2]))
21055
+ return json(res, 409, { error: "a new title is already being generated for this thread" });
21056
+ const excerpt = titleConversationExcerpt(store.messagesTail(m[2], 200).messages);
21057
+ if (!excerpt)
21058
+ return json(res, 409, { error: "this thread has no messages to take a title from yet" });
21059
+ try {
21060
+ assertWithinBudget(cfg, DATA_DIR);
21061
+ }
21062
+ catch (error) {
21063
+ return json(res, 409, { error: error instanceof Error ? error.message : String(error) });
21064
+ }
21065
+ const startedFrom = store.taskByThread(m[1], m[2]).title;
21066
+ titleRegenerations.add(m[2]);
21067
+ try {
21068
+ const title = await generateThreadTitle(instance, excerpt, "conversation");
21069
+ if (!title)
21070
+ return json(res, 502, { error: "couldn't generate a title — try again, or rename the thread" });
21071
+ const task = store.retitleTask(m[1], m[2], startedFrom, title) ?? store.taskByThread(m[1], m[2]);
21072
+ if (!task)
21073
+ return json(res, 404, { error: "no such task" });
21074
+ return json(res, 200, { task: wireTask(task) });
21075
+ }
21076
+ finally {
21077
+ titleRegenerations.delete(m[2]);
21078
+ }
21079
+ }
19747
21080
  // Named team Boats use real independent ownership, never a hidden bot or
19748
21081
  // an arbitrary provider id. These new routes remain admin-only by default.
19749
21082
  if (path === "/api/team-computers" && method === "GET") {
@@ -19937,7 +21270,7 @@ const handleRequest = async (req, res) => {
19937
21270
  // what the user's machine can host: which runtime is installed, whether
19938
21271
  // its daemon is up, and whether the desktop image and container exist
19939
21272
  if (method === "GET" && path === "/api/local-computer") {
19940
- return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET));
21273
+ return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET, auth));
19941
21274
  }
19942
21275
  if (method === "GET" && path === "/api/local-computer/instances") {
19943
21276
  res.setHeader("cache-control", "private, no-store");
@@ -19983,9 +21316,9 @@ const handleRequest = async (req, res) => {
19983
21316
  if (action === "stop" || action === "remove")
19984
21317
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).cancel();
19985
21318
  return json(res, 200, {
19986
- ...status,
21319
+ ...localVmViewerStatus(status, auth),
19987
21320
  commands: setupCommands(status.runtime, process.platform, SHARED_LOCAL_VM_TARGET),
19988
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
21321
+ idle_timeout_ms: localVmIdleMs(),
19989
21322
  mode: localVmMode(cfg),
19990
21323
  max_instances: localVmMaxInstances(cfg),
19991
21324
  });
@@ -20009,7 +21342,7 @@ const handleRequest = async (req, res) => {
20009
21342
  const bot = computerPreviewBot(m[1], url);
20010
21343
  if (!bot)
20011
21344
  return json(res, 404, { error: "no such bot" });
20012
- return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId)));
21345
+ return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId), auth));
20013
21346
  }
20014
21347
  m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/);
20015
21348
  if (m && method === "POST") {
@@ -20066,9 +21399,9 @@ const handleRequest = async (req, res) => {
20066
21399
  if (action === "stop" || action === "remove")
20067
21400
  localVmIdleFor(target).cancel();
20068
21401
  return json(res, 200, {
20069
- ...status,
21402
+ ...localVmViewerStatus(status, auth),
20070
21403
  commands: setupCommands(status.runtime, process.platform, target),
20071
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
21404
+ idle_timeout_ms: localVmIdleMs(),
20072
21405
  mode: localVmMode(cfg),
20073
21406
  max_instances: localVmMaxInstances(cfg),
20074
21407
  });
@@ -20233,7 +21566,7 @@ const handleRequest = async (req, res) => {
20233
21566
  return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
20234
21567
  }
20235
21568
  const kind = provider;
20236
- const saved = kind === "anthropic" ? cfg.anthropic : kind === "openaiCompat" ? cfg.openaiCompat : kind === "mistral" ? cfg.mistral : cfg.xai;
21569
+ const saved = { anthropic: cfg.anthropic, openai: cfg.openai, openrouter: cfg.openrouter, openaiCompat: cfg.openaiCompat, mistral: cfg.mistral, xai: cfg.xai }[kind];
20237
21570
  if (body?.key !== undefined && typeof body.key !== "string") {
20238
21571
  return json(res, 400, { error: "key must be a string" });
20239
21572
  }
@@ -20350,6 +21683,30 @@ const handleRequest = async (req, res) => {
20350
21683
  providerConfigBusy = false;
20351
21684
  }
20352
21685
  }
21686
+ if (method === "POST" && path === "/api/instances/chatgpt-accounts") {
21687
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json"))
21688
+ return json(res, 415, { error: "content-type must be application/json" });
21689
+ const parsed = createClaudeAccountSchema.pick({ displayName: true }).safeParse(await readBody(req, 8192));
21690
+ if (!parsed.success)
21691
+ return json(res, 400, { error: "Enter an account name (up to 80 characters)." });
21692
+ if (providerConfigBusy)
21693
+ return json(res, 409, { error: "provider settings are already being updated" });
21694
+ providerConfigBusy = true;
21695
+ try {
21696
+ const instances = persistableInstanceConfigs(cfg);
21697
+ const instanceId = `chatgpt-${randomUUID()}`;
21698
+ // Reuse the selected executable, never another account's environment.
21699
+ const cli = instances.chatgpt?.config?.cli
21700
+ ?? instances.codex?.config?.cli;
21701
+ instances[instanceId] = { driver: "codex", displayName: parsed.data.displayName,
21702
+ config: { authMode: "chatgpt-plan", ...(typeof cli === "string" && cli ? { cli } : {}) } };
21703
+ await persistProviderInstance(instanceId, instances);
21704
+ return json(res, 201, { instanceId, instances: await describeInstances() });
21705
+ }
21706
+ finally {
21707
+ providerConfigBusy = false;
21708
+ }
21709
+ }
20353
21710
  if (method === "POST" && path === "/api/instances/claude-accounts") {
20354
21711
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
20355
21712
  return json(res, 415, { error: "content-type must be application/json" });
@@ -20400,6 +21757,11 @@ const handleRequest = async (req, res) => {
20400
21757
  const instance = registry.get(instanceId);
20401
21758
  if (!instance)
20402
21759
  return json(res, 404, { error: "unknown instance" });
21760
+ // The official local OAuth callback terminates on this machine,
21761
+ // not a phone or the browser visiting a remotely hosted workspace.
21762
+ if (instance.authenticationMethod === "browser-pkce" && (isProxied(req) || !isLoopbackHost(req.socket.remoteAddress))) {
21763
+ return json(res, 403, { error: "Continue with ChatGPT on the computer running OpenMausBot. Hosted Pro sign-in requires OpenAI's hosted-app approval." });
21764
+ }
20403
21765
  const started = await providerAuthSessions.start(instance, owner);
20404
21766
  // Revocation can arrive while the CLI is obtaining a device code.
20405
21767
  if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
@@ -20623,13 +21985,78 @@ const handleRequest = async (req, res) => {
20623
21985
  res.once("close", disconnect);
20624
21986
  mcpProbesInFlight += 1;
20625
21987
  try {
20626
- return json(res, 200, await probeMcpServer(parsed.server, undefined, controller.signal));
21988
+ const name = mcpTest[1];
21989
+ let target = parsed.server;
21990
+ if (isRemoteMcpServer(target) && mcpOAuth.authState(name, target.url) === "signed-in") {
21991
+ const signedIn = (await withMcpSignIn({ [name]: target }, mcpOAuth))[name];
21992
+ if (signedIn)
21993
+ target = { ...signedIn, enabled: target.enabled };
21994
+ }
21995
+ const result = await probeMcpServer(target, undefined, controller.signal);
21996
+ if (!result.ok && result.auth === "required" && isRemoteMcpServer(target))
21997
+ mcpOAuth.markNeedsSignIn(name, target.url);
21998
+ return json(res, 200, result);
20627
21999
  }
20628
22000
  finally {
20629
22001
  res.off("close", disconnect);
20630
22002
  mcpProbesInFlight -= 1;
20631
22003
  }
20632
22004
  }
22005
+ // The loopback listener and authenticated paste-back complete the same flow.
22006
+ const mcpSignIn = /^\/api\/mcp\/servers\/([a-z][a-z0-9_-]{0,31})\/(sign-in|sign-out)(?:\/([0-9a-f-]{36}))?$/.exec(path);
22007
+ if (mcpSignIn) {
22008
+ const [, name, action, flowId] = mcpSignIn;
22009
+ const raw = cfg.mcpServers?.[name];
22010
+ const parsed = raw === undefined ? null : parseStoredMcpServer(name, raw);
22011
+ if (!parsed?.ok)
22012
+ return json(res, 404, { error: "MCP server not found." });
22013
+ if (!isRemoteMcpServer(parsed.server))
22014
+ return json(res, 400, { error: "Only servers reached by URL can be signed in to." });
22015
+ const url = parsed.server.url;
22016
+ if (action === "sign-out" && method === "POST" && !flowId) {
22017
+ await mcpOAuth.signOut(name, url);
22018
+ return json(res, 200, mcpServerResponse());
22019
+ }
22020
+ const owner = auth.kind === "session" ? auth.session.id : "loopback";
22021
+ res.setHeader("Cache-Control", "no-store");
22022
+ try {
22023
+ if (action === "sign-in" && method === "GET" && flowId) {
22024
+ const status = mcpOAuth.status(name, flowId, owner);
22025
+ return status ? json(res, 200, { auth: status }) : json(res, 404, { error: "This sign-in is no longer available in this browser. Start again." });
22026
+ }
22027
+ if (action === "sign-in" && method === "DELETE") {
22028
+ mcpOAuth.cancelFlow(name, owner, flowId);
22029
+ return json(res, 200, { ok: true });
22030
+ }
22031
+ if (action === "sign-in" && method === "POST" && flowId) {
22032
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
22033
+ return json(res, 415, { error: "content-type must be application/json" });
22034
+ }
22035
+ const body = await readBody(req, 20_480);
22036
+ if (typeof body?.callbackUrl !== "string")
22037
+ return json(res, 400, { error: "A complete callbackUrl is required." });
22038
+ return json(res, 200, { auth: await mcpOAuth.completeCallback(name, flowId, body.callbackUrl, owner) });
22039
+ }
22040
+ if (action === "sign-in" && method === "POST" && !flowId) {
22041
+ const started = await mcpOAuth.start(name, url, undefined, owner);
22042
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
22043
+ mcpOAuth.revokeOwner(owner);
22044
+ return json(res, 401, { error: "Your session ended. Start a new sign-in." });
22045
+ }
22046
+ return json(res, 200, { auth: started });
22047
+ }
22048
+ }
22049
+ catch (error) {
22050
+ if (error instanceof McpSignInError)
22051
+ return json(res, error.status, { error: error.message });
22052
+ if (error instanceof McpOAuthError)
22053
+ return json(res, 400, { error: error.message, code: error.code });
22054
+ if (method === "POST" && !flowId)
22055
+ return json(res, 502, { error: error instanceof Error ? error.message : "Sign-in could not start." });
22056
+ throw error;
22057
+ }
22058
+ return json(res, 405, { error: "method not allowed" });
22059
+ }
20633
22060
  if (method === "POST" && path === "/api/mcp/servers") {
20634
22061
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
20635
22062
  return json(res, 415, { error: "content-type must be application/json" });
@@ -20715,6 +22142,7 @@ const handleRequest = async (req, res) => {
20715
22142
  const next = { ...current };
20716
22143
  delete next[name];
20717
22144
  persistMcpServers(next);
22145
+ mcpOAuth.forget(name);
20718
22146
  return json(res, 200, mcpServerResponse());
20719
22147
  }
20720
22148
  const existing = parseStoredMcpServer(name, current[name]);
@@ -20736,6 +22164,17 @@ const handleRequest = async (req, res) => {
20736
22164
  if (refusal)
20737
22165
  return json(res, 403, { error: refusal, code: "managed_policy" });
20738
22166
  persistMcpServers({ ...current, [name]: parsed.server });
22167
+ // a sign-in belongs to one address; a token never follows the entry elsewhere
22168
+ const before = isRemoteMcpServer(existing.server) ? existing.server.url : null;
22169
+ const after = isRemoteMcpServer(parsed.server) ? parsed.server.url : null;
22170
+ // ...and to one sign-in app with its scopes; a new secret for the same app keeps it
22171
+ const app = (server) => isRemoteMcpServer(server) && server.oauth
22172
+ ? JSON.stringify([server.oauth.clientId, server.oauth.scopes ?? []]) : null;
22173
+ // Any other edit (a new header, say) drops a sign-in request that has
22174
+ // no tokens behind it; the next Test asks again if it still applies.
22175
+ if (before !== after || app(existing.server) !== app(parsed.server)
22176
+ || (after && mcpOAuth.authState(name, after) === "needs-sign-in"))
22177
+ mcpOAuth.forget(name);
20739
22178
  return json(res, 200, mcpServerResponse());
20740
22179
  }
20741
22180
  finally {
@@ -20748,9 +22187,13 @@ const handleRequest = async (req, res) => {
20748
22187
  }
20749
22188
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
20750
22189
  const body = await readBody(req);
20751
- if (hostedModels && ["instances", "anthropic", "openaiCompat", "xai", "mistral", "opencodeGo"].some(key => Object.hasOwn(body, key)))
22190
+ if (hostedModels && ["instances", "anthropic", "openai", "openrouter", "openaiCompat", "xai", "mistral", "opencodeGo"].some(key => Object.hasOwn(body, key)))
20752
22191
  return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
20753
22192
  const patch = parseConfigPatch(body);
22193
+ // A Cloud home is personal: nobody is invited to sign in to it.
22194
+ if (CLOUD_HOME && (patch.signIn?.admins?.length || patch.signIn?.members?.length)) {
22195
+ return json(res, 403, { error: CLOUD_PERSONAL_REFUSAL, code: "cloud_personal" });
22196
+ }
20754
22197
  if (patch.newBotDefaults) {
20755
22198
  if (patch.newBotDefaults.profile.modelSelection) {
20756
22199
  const checked = checkedModelSelection(patch.newBotDefaults.profile.modelSelection);
@@ -20783,6 +22226,14 @@ const handleRequest = async (req, res) => {
20783
22226
  }
20784
22227
  if (!Object.keys(patch).length)
20785
22228
  return json(res, 400, { error: "nothing to save" });
22229
+ if (patch.decider) {
22230
+ // Saving a key is the "turn it on"; clearing it turns it off, or
22231
+ // falls back to Cloud Pro's included decisions where there are some.
22232
+ const planned = deciderSavePatch(patch.decider, cfg.decider, deciderIncludedHere());
22233
+ if (!planned.ok)
22234
+ return json(res, 400, { error: planned.error });
22235
+ patch.decider = planned.patch;
22236
+ }
20786
22237
  const changingVoiceProvider = patch.tts?.provider !== undefined
20787
22238
  && patch.tts.provider !== tts.voiceProvider(cfg);
20788
22239
  if (changingVoiceProvider && patch.tts?.voice === undefined) {
@@ -21072,6 +22523,15 @@ const handleRequest = async (req, res) => {
21072
22523
  if (!check.ok)
21073
22524
  return json(res, 400, { error: check.message });
21074
22525
  }
22526
+ // A pasted decision-model key is tried once (one tiny yes/no call).
22527
+ // Only a refusal blocks the save: an offline or overloaded service must
22528
+ // not stop a person saving a key that may well be fine.
22529
+ if (patch.decider?.key) {
22530
+ const check = await decider.testKey({ key: patch.decider.key });
22531
+ if (!check.ok && check.reason === "rejected") {
22532
+ return json(res, 400, { error: "Jev rejected this key. Check it at typesafe.ai and paste it again.", code: "decider_rejected" });
22533
+ }
22534
+ }
21075
22535
  if (patch.browserProfiles !== undefined) {
21076
22536
  // Provider/credential validation above may await the network. A turn
21077
22537
  // can start during that window and claim a profile which looked idle
@@ -21139,6 +22599,8 @@ const handleRequest = async (req, res) => {
21139
22599
  persisted.tts.key = "";
21140
22600
  if (persisted.tts?.fishKey !== undefined)
21141
22601
  persisted.tts.fishKey = "";
22602
+ if (persisted.decider?.key !== undefined)
22603
+ persisted.decider.key = "";
21142
22604
  if (persisted.imageGen?.key !== undefined)
21143
22605
  persisted.imageGen.key = "";
21144
22606
  if (persisted.imageGen?.customApiKey !== undefined)
@@ -21171,10 +22633,18 @@ const handleRequest = async (req, res) => {
21171
22633
  }
21172
22634
  throw error;
21173
22635
  }
22636
+ // Desktops already counting down move to the new window at once, in
22637
+ // every isolation mode; busy work still defers its expiry.
22638
+ if (patch.localVm?.idleTimeoutMinutes !== undefined) {
22639
+ for (const idle of localVmIdles.values())
22640
+ idle.setIdleMs(localVmIdleMs());
22641
+ }
21174
22642
  let browserReferenceCleanupError = null;
21175
- if (patch.signIn !== undefined)
22643
+ if (patch.signIn !== undefined) {
21176
22644
  sessions.revalidateEmailSessions();
21177
- if (!sharedComputersEnabled(cfg)) {
22645
+ resyncOpenCodeProviderKeys();
22646
+ }
22647
+ if (!lendingEnabled()) {
21178
22648
  sharedComputers.close();
21179
22649
  sharedComputerControl.close();
21180
22650
  }
@@ -21634,6 +23104,10 @@ const handleRequest = async (req, res) => {
21634
23104
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21635
23105
  return json(res, 415, { error: "content-type must be application/json" });
21636
23106
  }
23107
+ if (m[2] === "join" && bot.cloudBackend === "vps") {
23108
+ for (let pending; (pending = vpsJoinRequests.get(botId));)
23109
+ await pending;
23110
+ }
21637
23111
  const remoteProvider = bot.cloudBackend === "vps" ? "vps" : "box";
21638
23112
  if (computerProviderConfigTransitions.has(remoteProvider)) {
21639
23113
  return json(res, 409, { error: providerTransitionMessage(remoteProvider) });
@@ -21681,6 +23155,10 @@ const handleRequest = async (req, res) => {
21681
23155
  // Opening the existing SSH viewer can coexist with a capture. Start,
21682
23156
  // stop, remove and Settings deletion still exclude pending previews.
21683
23157
  const releaseComputerLifecycle = claimBotComputerLifecycle(botId, m[2] === "join");
23158
+ // Settles only after the lane is released, so a waiter can claim it.
23159
+ let joinSettled;
23160
+ if (m[2] === "join")
23161
+ vpsJoinRequests.set(botId, new Promise(resolve => { joinSettled = resolve; }));
21684
23162
  try {
21685
23163
  if (m[2] === "exec") {
21686
23164
  return json(res, 409, { error: "the VPS console is available to the bot through its scoped computer tools" });
@@ -21692,13 +23170,20 @@ const handleRequest = async (req, res) => {
21692
23170
  return json(res, 409, { error: "the VPS computer is being used by this bot — interrupt the turn first" });
21693
23171
  }
21694
23172
  if (m[2] === "join") {
21695
- return json(res, 200, await vps.vpsComputerJoin(cfg, botId));
23173
+ const remote = auth.kind !== "loopback";
23174
+ const result = await vps.vpsComputerJoin(cfg, botId, undefined, remote);
23175
+ return json(res, 200, { ...result, joinUrl: remote
23176
+ ? desktopViewerUrl(`vps/${botId}`, bot.threadId) : result.joinUrl });
21696
23177
  }
21697
23178
  const action = m[2] === "provision" ? "provision" : m[2] === "remove" ? "remove" : "stop";
21698
23179
  return json(res, 200, await vps.vpsComputerAction(action, cfg, botId));
21699
23180
  }
21700
23181
  finally {
21701
23182
  releaseComputerLifecycle();
23183
+ if (joinSettled) {
23184
+ vpsJoinRequests.delete(botId);
23185
+ joinSettled();
23186
+ }
21702
23187
  }
21703
23188
  }
21704
23189
  const activeBoatTurn = botHasActiveTurn(botId);
@@ -21763,6 +23248,7 @@ const handleRequest = async (req, res) => {
21763
23248
  }
21764
23249
  };
21765
23250
  const server = createServer(handleRequest);
23251
+ desktopViewer.attach(server, handleRequest);
21766
23252
  calendarCalls.start();
21767
23253
  // Resolve the edition before accepting requests so /api/edition is never a guess.
21768
23254
  console.log(describeEdition(await loadEnterpriseLayer()));
@@ -21943,6 +23429,7 @@ if (TUNNEL_SOCKET) {
21943
23429
  if (process.platform !== "win32")
21944
23430
  rmSync(TUNNEL_SOCKET, { force: true });
21945
23431
  tunnelListener = createServer(handleRequest);
23432
+ desktopViewer.attach(tunnelListener, handleRequest);
21946
23433
  tunnelListener.listen(TUNNEL_SOCKET, () => {
21947
23434
  console.log(`openmausbot tunnel listener on ${TUNNEL_SOCKET}`);
21948
23435
  });
@@ -21961,6 +23448,7 @@ const gracefulShutdown = createGracefulShutdown({
21961
23448
  sharedComputers.close();
21962
23449
  sharedComputerControl.close();
21963
23450
  browserLive.closeAll();
23451
+ desktopViewer.closeAll();
21964
23452
  for (const idle of localVmIdles.values())
21965
23453
  idle.cancel();
21966
23454
  vps.closeAllVpsDesktopTunnels();
@@ -21997,8 +23485,10 @@ const gracefulShutdown = createGracefulShutdown({
21997
23485
  code = 1;
21998
23486
  }
21999
23487
  closeMessageDb();
23488
+ mcpOAuth.dispose();
22000
23489
  releaseDataDirLeaseAtExit();
22001
- process.exit(code);
23490
+ // A Cloud home's launcher starts the server again on this code only.
23491
+ process.exit(cloudHomeRestartRequested && code === 0 ? CLOUD_HOME_RESTART_EXIT_CODE : code);
22002
23492
  },
22003
23493
  });
22004
23494
  for (const signal of ["SIGINT", "SIGTERM"]) {