openmausbot 0.1.91 → 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 (150) hide show
  1. package/dist/assets/DesktopViewer-Do6lBa4C.js +5 -0
  2. package/dist/assets/{abnfDiagram-O67JEVCF-yP-w8pRq.js → abnfDiagram-O67JEVCF-Cd3X7Hxc.js} +1 -1
  3. package/dist/assets/{arc-DsivfZEh.js → arc-DIYc7Ymx.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CILw7lzT.js → architectureDiagram-NJMV4G6O-DBmRGVyP.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-DEQUV3HF.js → blockDiagram-BEXU5L5S-CPFePSEE.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-lSXCZoKA.js → c4Diagram-YGBWAQC7-DD_DO1qy.js} +1 -1
  7. package/dist/assets/channel-Dtk5Mtic.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-QCD5bgWP.js → chunk-2Q5K7J3B-BNEeNSjX.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-C5-iZJuL.js → chunk-3FUC2YCW-BbinLaqH.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-cxsawMpY.js → chunk-5VM5RSS4-BBgYrgDR.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-DV8tV8H7.js → chunk-GTNCS2PH-ezXj-kA-.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CJFZyfbW.js → chunk-GWA4HPMP-9FxZ3diV.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-BigbMXco.js → chunk-JWPE2WC7-CO-40JpI.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-oVxTfpDy.js → chunk-NETBCI7D-BEhjslYE.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CH4BiroW.js → chunk-WEXAMYUT-CNeq4n7_.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-KEDa6gax.js → chunk-XXDRQBXY-OsCY7KSi.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BiPjfqVv.js → classDiagram-v2-NBCMYWYE-CloRUEtk.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-Caxew3OY.js → cose-bilkent-JH36ORCC-D1BGZhp4.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-B_Pui369.js → cynefin-OW5HDTMX-CUf4T2CN.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-X64ydIhP.js → cynefinDiagram-VND7K2PF-DPEZrFGA.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-TqYCI1E1.js → dagre-6A5THRUB-bDe6GIv5.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-TeOt3rAQ.js → diagram-22UHCM2B-CdcUQz55.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-D70BLhDA.js → diagram-3UASUU5V-BZpqGg7c.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-BbQDLwUC.js → diagram-ATOU4E4O-DvGM6ugp.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-ChNJZ1u-.js → diagram-CDSNMT55-DSTmMm0s.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-DMBl2APa.js → diagram-MLGK6HIB-15cucnMn.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-CEqDchOp.js → diagram-MPIPVDR6-tMbuaE6n.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BVDKRp_-.js → ebnfDiagram-ZINNZB2B-BYxYzjHI.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CRkofO9B.js → elk-276RUBZZ-v6bj852W.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-DruOMh5e.js → erDiagram-OPXOYQCR-5lBBlnd2.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-8GMQeB7t.js → flowDiagram-KWPJA3E3-CZyl1zIj.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-ORE9jcsM.js → ganttDiagram-FUAMR5RP-BiAI-ltZ.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BmsedsWh.js → gitGraphDiagram-X574FWY7-BAZISkj3.js} +1 -1
  34. package/dist/assets/index-Cdmjrl28.css +1 -0
  35. package/dist/assets/{index-DKNEM8Q6.js → index-DGBaEXrM.js} +1 -1
  36. package/dist/assets/index-DR7YPMkW.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-E_p7AooJ.js → infoDiagram-VRGFBTTK-B6ooOy-Z.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-Y-B2mKda.js → ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-eA8RELcK.js → journeyDiagram-ZHPQQLJL-DWqlzIEe.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DqIL2e8e.js → kanban-definition-PNTS6WVX-JX6piDwL.js} +1 -1
  41. package/dist/assets/{linear-yC_29R6e.js → linear-CmOWzX0P.js} +1 -1
  42. package/dist/assets/{mermaid.core-VhEMkSND.js → mermaid.core-CF1zwoQ-.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-oJ7kyQZy.js → mindmap-definition-NLK3R4M7-CGh2K4fW.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BmfYcY0O.js → pegDiagram-GJSIUBJH-CDwYN0s7.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-DKvgP7w9.js → pieDiagram-5QR66LMP-1rfjJocB.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-BEKL3W0L.js → quadrantDiagram-O4NWA36T-tKPvPiok.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BEsiqR5s.js → railroadDiagram-XR7U4H2S-BHLm5vXR.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DWBcWvAP.js → requirementDiagram-PLB6GJNP-CB45STp7.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-rpZ2_O4s.js → sankeyDiagram-IPEJSGJF-55QA_huC.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-B99HQdJR.js → sequenceDiagram-PO4LG4MO-BckFmQHn.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BjN0qT5T.js → sizeCapture-INFHLROL-BFCOJF11.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BbsVgGsC.js → stateDiagram-v2-GCMORJYK-CZvmiVVN.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DG3gKkeR.js → swimlanes-2SLR337P-BpDUCEKy.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BTHOdtF_.js → timeline-definition-EJHVYXUP-BpxHq6IK.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-acLSLFwc.js → usecaseDiagram-POWQR4AR-Cj66vm02.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C5JzNX9G.js → vennDiagram-UO4OBE2U-C9nRkWE_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DL1-PrR_.js → wardleyDiagram-VNRHLVJA-D-me_nWj.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Bvr1477p.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 +112 -14
  63. package/dist-server/container-mcp.js +195 -15
  64. package/dist-server/drivers/agents-proxy.js +5 -4
  65. package/dist-server/enterprise/server/index.js +5 -1
  66. package/dist-server/index.js +9300 -4155
  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 +3910 -1182
  71. package/dist-server/pair-cli.js +3910 -1182
  72. package/dist-server/server/boat.js +2 -2
  73. package/dist-server/server/browser-engine.js +10 -1
  74. package/dist-server/server/browser-live.js +46 -2
  75. package/dist-server/server/browser-runtime.js +74 -8
  76. package/dist-server/server/browser-tool-shape.js +6 -4
  77. package/dist-server/server/chief-of-staff.js +1 -1
  78. package/dist-server/server/cloud-home-start.js +118 -26
  79. package/dist-server/server/cloud-home.js +39 -3
  80. package/dist-server/server/cloud-lending.js +74 -10
  81. package/dist-server/server/cloud-owner.js +196 -0
  82. package/dist-server/server/cloud-secrets-boot.js +6 -0
  83. package/dist-server/server/cloud-secrets.js +65 -0
  84. package/dist-server/server/config.js +174 -14
  85. package/dist-server/server/container-computer.js +3 -2
  86. package/dist-server/server/desktop-viewer-targets.js +26 -0
  87. package/dist-server/server/drivers/acp/core.js +167 -21
  88. package/dist-server/server/drivers/acp/opencode-go.js +614 -85
  89. package/dist-server/server/drivers/agents-catalog.js +6 -6
  90. package/dist-server/server/drivers/antigravity-acp.js +69 -11
  91. package/dist-server/server/drivers/antigravity-temp.js +360 -0
  92. package/dist-server/server/drivers/antigravity.js +21 -0
  93. package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
  94. package/dist-server/server/drivers/claude.js +312 -34
  95. package/dist-server/server/drivers/codex.js +228 -18
  96. package/dist-server/server/drivers/grok.js +2 -2
  97. package/dist-server/server/drivers/mistral.js +4 -3
  98. package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
  99. package/dist-server/server/drivers/openai-chat.js +41 -3
  100. package/dist-server/server/drivers/openai-compat.js +73 -23
  101. package/dist-server/server/engine-install.js +46 -12
  102. package/dist-server/server/env-path.js +15 -3
  103. package/dist-server/server/harness/registry.js +2 -2
  104. package/dist-server/server/index.js +1338 -184
  105. package/dist-server/server/lending-memory.js +479 -0
  106. package/dist-server/server/local-vm-idle.js +24 -6
  107. package/dist-server/server/managed-desktop.js +3 -0
  108. package/dist-server/server/mcp-bridge.js +5 -3
  109. package/dist-server/server/mcp-http.js +6 -3
  110. package/dist-server/server/mcp-oauth-discovery.js +133 -0
  111. package/dist-server/server/mcp-oauth-store.js +70 -0
  112. package/dist-server/server/mcp-oauth.js +550 -0
  113. package/dist-server/server/mcp-probe.js +14 -1
  114. package/dist-server/server/mcp-registry.js +43 -0
  115. package/dist-server/server/memory-upkeep.js +94 -84
  116. package/dist-server/server/model-catalog/catalog.js +374 -0
  117. package/dist-server/server/model-catalog/presets.js +176 -0
  118. package/dist-server/server/model-catalog/trim.js +219 -0
  119. package/dist-server/server/provider-key-check.js +4 -2
  120. package/dist-server/server/recent-work.js +1 -1
  121. package/dist-server/server/request-auth.js +23 -0
  122. package/dist-server/server/routes/antigravity-leftovers.js +45 -0
  123. package/dist-server/server/routes/bot-memory.js +31 -6
  124. package/dist-server/server/routes/desktop-viewer.js +194 -0
  125. package/dist-server/server/routine-requests.js +1 -1
  126. package/dist-server/server/routines.js +1 -1
  127. package/dist-server/server/sessions.js +56 -9
  128. package/dist-server/server/skills.js +25 -10
  129. package/dist-server/server/store.js +62 -122
  130. package/dist-server/server/thread-events.js +2 -0
  131. package/dist-server/server/thread-starters.js +33 -0
  132. package/dist-server/server/thread-title.js +46 -0
  133. package/dist-server/server/tts/fish.js +4 -5
  134. package/dist-server/server/tts/index.js +5 -3
  135. package/dist-server/server/vps-computer.js +79 -12
  136. package/dist-server/server/webhooks.js +18 -1
  137. package/dist-server/server/workspace-backup-policy.js +3 -0
  138. package/dist-server/server/workspace-backup.js +3 -0
  139. package/dist-server/server/workspace.js +65 -15
  140. package/dist-server/shared/workspace-backup-client.js +1 -0
  141. package/dist-server/src/lib/citations.js +177 -0
  142. package/dist-server/src/lib/composer-attachments.js +7 -1
  143. package/dist-server/vps-container-mcp.js +196 -15
  144. package/dist-server/workspace-backup.worker.js +73 -9
  145. package/enterprise/server/index.js +5 -1
  146. package/package.json +1 -1
  147. package/dist/assets/channel-DrfGMu7X.js +0 -1
  148. package/dist/assets/index-BlnCEcU6.js +0 -588
  149. package/dist/assets/index-DVxjOvID.css +0 -1
  150. package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +0 -8
@@ -1,6 +1,9 @@
1
1
  // OpenMausBot server — the harness host. Clients hold no transports
2
2
  // (upstream rule): the React app dispatches typed commands over HTTP and
3
3
  // folds one SSE event stream; every provider process runs here.
4
+ // First, before any module that could start a process: a Cloud home's
5
+ // secrets off the launcher's pipe (cloud-secrets-boot.ts).
6
+ import { BOOT_CLOUD_SECRETS } from "./cloud-secrets-boot.js";
4
7
  import { AsyncLocalStorage } from "node:async_hooks";
5
8
  import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
6
9
  import { existsSync, readFileSync, realpathSync, rmSync, mkdirSync } from "node:fs";
@@ -16,7 +19,8 @@ import { draftSummary, foldPoint } from "./compaction-summary.js";
16
19
  import { compactBudget, contextWindowFor, shouldCompact } from "./context-budget.js";
17
20
  import { autoCompactWindow } from "./drivers/claude.js";
18
21
  import { provenRequestPerson, SharedComputers, sharedComputerOperation, sharedComputerRegistration } from "./shared-computers.js";
19
- import { cloudHomeLendingRefusal, createCloudRoutineAuthors } from "./cloud-lending.js";
22
+ import { cloudHomeLendingRefusal, createCloudRoutineAuthors, ownerOnlyConversation } from "./cloud-lending.js";
23
+ import { botMemoryFiles, createLendingMemory } from "./lending-memory.js";
20
24
  import { SharedComputerControl } from "./shared-computer-control.js";
21
25
  import { RoomHandoffs } from "./room-handoffs.js";
22
26
  import { assertRequestTarget, guardedRequestPath, requestConflict, requestNeedsInput, requestSourceForCard } from "./guarded-requests.js";
@@ -61,7 +65,7 @@ import { canAccessTeam, canReachPeer, coordinatorSupervises, livePeerRoster, liv
61
65
  import { openMausStatusSystemPrompt } from "./openmaus-status-capsule.js";
62
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";
63
67
  import { attachForTurn, saveBotAttachment } from "./bot-attachment.js";
64
- import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, } from "./config.js";
68
+ import { ensureDirs, instanceConfigs, loadConfig, providerReloadKeys, localVmIdleTimeoutMinutes, localVmMaxInstances, localVmMode, parseConfigPatch, roomTurnTimeoutMinutes, threadEventLogMaxBytes, maxConcurrentBotThreads, threadEventLogRetentionDays, saveConfig, showToolCallsEnabled, routinesInConversationEnabled, claudeUserMcpEnabled, skillAuthoringEnabled, autoRecallEnabled, captureQuietMs, tidyHour, sharedComputersEnabled, builtInBrowserEnabled, llmThreadTitlesEnabled, computerClaimIdleReleaseEnabled, cloudOverflowAllowlistedThreads, cloudOverflowEnabled, cloudOverflowIdleStopMs, cloudOverflowPerSecondCostUsd, browserProfileReplacementConflict, browserProfilePartitionTarget, syncCredentialEnv, stripWorkspaceCredentialEnv, PROVIDER_CREDENTIAL_ENV, withInstanceCli, persistableInstanceConfigs, vpsSshAlias, browserEngineAttachCdpUrl, DATA_DIR, EVENTS_DIR, NATIVE_DIR, customMcpServers, roomHandoffLimits, onConfigSaved, CLAUDE_API_INSTANCE, } from "./config.js";
65
69
  import { sweepThreadEventLogs } from "./thread-retention.js";
66
70
  import { ComputerControl } from "./computer-control.js";
67
71
  import { augmentedPath, findCliCandidates, resetPathCache } from "./env-path.js";
@@ -80,10 +84,12 @@ import { isModelVariant, TurnNotStartedError, newId, } from "./contracts.js";
80
84
  import { RETRY_MAX_ATTEMPTS } from "./drivers/retry.js";
81
85
  import { recoveryCapabilityError } from "./automatic-recovery.js";
82
86
  import { decodeGeneratedImage } from "./generated-image.js";
83
- import { MAX_MCP_SERVERS, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
87
+ import { MAX_MCP_SERVERS, isRemoteMcpServer, listMcpServers, mcpServerNameError, parseMcpServerMutation, parseMcpServersImport, parseStoredMcpServer, } from "./mcp-registry.js";
88
+ import { McpOAuthError, McpSignInError, McpOAuthManager, mcpOAuthRedirectUri, withMcpSignIn, withoutPendingSignIn } from "./mcp-oauth.js";
84
89
  import { probeMcpServer } from "./mcp-probe.js";
85
90
  import { GROUP_GOAL_MAX_TURNS, groupGoalAssignmentKey, groupGoalCompletionTurnId, groupGoalCoordinatorInstructions, groupGoalWorkerInstructions, parseGroupGoalDecision, resolveGroupGoalMember, selectGroupGoalCoordinator, } from "./group-goal-run.js";
86
91
  import { BUILT_IN_DRIVERS } from "./drivers/builtIn.js";
92
+ import { openCodeProviderKeysAllowed, setOpenCodeProviderKeyPolicy } from "./drivers/acp/opencode-go.js";
87
93
  import { getOrCreateChannel, mirrorActivity, mirrorExchange, mirrorReply } from "./comms-visibility.js";
88
94
  import { readMessageText, recallMessages, recentMessages, searchMessagesAsync, closeMessageSearch, closeMessageDb, chatFollowups, cancelledChatFollowup, settleChatFollowups, threadsReferencing } from "./message-db.js";
89
95
  import { briefCrossingLabel, claimRecallCrossings, recallCrossingLabel } from "./recall-disclosure.js";
@@ -104,7 +110,8 @@ import { ProviderRegistry } from "./harness/registry.js";
104
110
  import { ManagedDesktopProviders } from "./managed-desktop.js";
105
111
  import { computerKindForResource, ManagedDesktopPolicy } from "./managed-policy.js";
106
112
  import { hostedModelPolicy, HOSTED_MODEL_POLICY_HEADER, HOSTED_PROVIDER_SETTINGS_ERROR } from "./hosted-models.js";
107
- import { boatNotConfiguredMessage, CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, readSignedBody, } from "./cloud-home.js";
113
+ import { boatNotConfiguredMessage, CLOUD_HOME_RESTART_EXIT_CODE, CLOUD_HOME_SECRET_KEYS, CLOUD_IGNORED_KEYS, CLOUD_PAIRING_PATH, cloudHomeConfiguration, cloudHomeOffersPlace, cloudHomePlaceRefusal, createCloudPairing, firstCloudTurnPatch, readSignedBody, } from "./cloud-home.js";
114
+ import { CLOUD_PERSONAL_REFUSAL, settleCloudOwnership } from "./cloud-owner.js";
108
115
  import { createCloudMoveRoutes } from "./cloud-move-http.js";
109
116
  import { holdIncludedServices } from "./included-services.js";
110
117
  import { selectDefaultModelSelection, withNewBotEffort } from "./default-model-selection.js";
@@ -120,10 +127,11 @@ import { narrateTool, toUtterances } from "./tts/speech-text.js";
120
127
  import { buildRecoveryText, buildTurnContext, engineIsFresh, NATIVELY_REPLAYING_DRIVER_KINDS, peerMessageText } from "./turn-context.js";
121
128
  import { Handoffs, handedStateUsable, recordHanded, renderUnseen, sessionStart, unseenMessages, withUnseenMessages } from "./delta-context.js";
122
129
  import { extractTurnImages } from "./turn-images.js";
130
+ import { threadTitlePrompt, titleConversationExcerpt } from "./thread-title.js";
123
131
  import { TurnWatchdog } from "./turn-watchdog.js";
124
132
  import { TurnResources, workspaceResource } from "./turn-resources.js";
125
133
  import { IdleReleasePolicy } from "./claim-idle.js";
126
- import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, workspaceDir, } from "./workspace.js";
134
+ import { ensureWorkspace, ensureTaskWorkspace, workspaceLocationsPrompt, supportsWorkspaceFiles, updateMemory, appendMemoryLog, memorySystemPrompt, memorySourceLabel, searchMemoryFiles, SESSION_SEARCH_SYSTEM_PROMPT, TASK_WORKSPACES_DIR, readMemoryOnlyFromRegularFiles, workspaceDir, } from "./workspace.js";
127
135
  import { listMemoryTopics, memoryDate, readMemoryFile, readMemoryTopic, writeMemoryFile, writeMemoryTopic } from "./workspace.js";
128
136
  import { beginMemoryTurn, endMemoryTurn, flushAllMemoryJournals, journalMemoryWrite, } from "./memory-journal.js";
129
137
  import { readSectionContext, readSections, sectionContextKey, sectionContextLabel, sectionContextSystemPrompt, writeSectionContext, SECTION_CONTEXT_MAX_BYTES, } from "./section-context.js";
@@ -146,12 +154,12 @@ import * as vps from "./vps-computer.js";
146
154
  import { RoutineManager } from "./routines.js";
147
155
  import { CalendarCallManager } from "./calendar-calls.js";
148
156
  import { BUILT_IN_BROWSER_SYSTEM_PROMPT } from "./browser-engine.js";
149
- import { BrowserRuntime } from "./browser-runtime.js";
157
+ import { BrowserRuntime, browserRuntimeEnv } from "./browser-runtime.js";
150
158
  import { BrowserLive } from "./browser-live.js";
151
- 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";
152
160
  import { createScreenFrameSource } from "./screen-frame-source.js";
153
161
  import { screenFrameHash, screenSurfaceForTool, screenTouchingTool, settledFrameIsNews } from "./screen-frame-gate.js";
154
- import { RoutineRequestService } from "./routine-requests.js";
162
+ import { asSchedule, RoutineRequestService } from "./routine-requests.js";
155
163
  import { createOptionsCard } from "./options-card.js";
156
164
  import { buildBotOverview, connectedAppsFacts } from "./bot-overview.js";
157
165
  import { ProfileRequestService } from "./profile-requests.js";
@@ -214,6 +222,10 @@ import { createHostedSlackRoutes } from "./routes/hosted-slack.js";
214
222
  import { createBotPresetRoutes } from "./routes/bot-presets.js";
215
223
  import { createBotMemoryRoutes } from "./routes/bot-memory.js";
216
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";
217
229
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
218
230
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
219
231
  // Behind a proxy or tunnel, the base URL senders should use (docs/self-hosting.md).
@@ -223,6 +235,7 @@ const MIME = {
223
235
  ".html": "text/html",
224
236
  ".js": "text/javascript",
225
237
  ".css": "text/css",
238
+ ".txt": "text/plain; charset=utf-8",
226
239
  ".svg": "image/svg+xml",
227
240
  ".png": "image/png",
228
241
  ".ico": "image/x-icon",
@@ -252,8 +265,12 @@ process.once("exit", releaseDataDirLeaseAtExit);
252
265
  // Restore before constructing any long-lived config, Store, session or provider
253
266
  // objects. Replacing files underneath a live Store would overwrite restored data.
254
267
  let workspaceRestore = { restored: false };
268
+ /** A restore applied at this very start (not the last one on record). */
269
+ let restoredAtStart;
255
270
  if (existsSync(join(DATA_DIR, ".backups"))) {
256
271
  workspaceRestore = applyPendingWorkspaceRestore(DATA_DIR);
272
+ if (workspaceRestore.restored)
273
+ restoredAtStart = workspaceRestore.id;
257
274
  if (!workspaceRestore.restored && !workspaceRestore.rolledBack) {
258
275
  workspaceRestore = readLastWorkspaceRestore(DATA_DIR) ?? workspaceRestore;
259
276
  }
@@ -279,7 +296,16 @@ const sessions = new SessionRegistry({
279
296
  const sharedComputers = new SharedComputers(id => sessions.isLive(id));
280
297
  // OMB Cloud Pro home machine (server/cloud-home.ts, docs/cloud-pro.md). A
281
298
  // partial or invalid boot contract stops the server here, before it serves.
282
- 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);
283
309
  /** Lending a computer to this server (the shared-computer routes, the two
284
310
  * agent tools, the advertised capability): the maintainer flag anywhere, and
285
311
  * always on an OMB Cloud home, where it is the person's own Mac lent to their
@@ -291,6 +317,29 @@ const CLOUD_HOME_LENDER = "cloud-home";
291
317
  * the owner started: the only routine turns that may use a lent Mac. */
292
318
  const cloudRoutineAuthors = CLOUD_HOME ? createCloudRoutineAuthors(join(DATA_DIR, "lending-routines.json")) : null;
293
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();
294
343
  const cloudPairing = CLOUD_HOME ? createCloudPairing({ secret: CLOUD_HOME.bootstrapSecret, sessions }) : null;
295
344
  if (CLOUD_HOME) {
296
345
  // The signing secret is held in memory from here on, and a platform
@@ -305,7 +354,12 @@ if (CLOUD_HOME) {
305
354
  }
306
355
  // Cloud Pro's included Boat, voice and decision relay tokens, when the Admin
307
356
  // set them: held in memory from here on, like the signing secret.
308
- holdIncludedServices();
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
+ }
309
363
  // Who each thread is for, when a signed-in person can be named (server-private).
310
364
  const threadStarters = new ThreadStarters(join(DATA_DIR, "thread-starters.json"));
311
365
  const commandAllowlist = new CommandAllowlistStore(join(DATA_DIR, "command-allowlist.json"));
@@ -318,7 +372,7 @@ const sharedWorkspaceFullAccessEnabled = () => SHARED_WORKSPACE_FULL_ACCESS && B
318
372
  // Who a loopback request without a session is (server/request-auth.ts
319
373
  // LoopbackTrust): the owner on a desktop or a one-person server; a service on
320
374
  // a shared workspace, where every bot's shell is a loopback caller too.
321
- const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE });
375
+ const LOOPBACK = resolveLoopbackTrust({ desktopManaged: DESKTOP_MANAGED, hostedWorkspace: HOSTED_WORKSPACE, cloudHome: Boolean(CLOUD_HOME) });
322
376
  // `openmausbot serve` on a service-trust server hands the server it starts a
323
377
  // per-launch secret on stdin, then closes it (server/cli.ts). It opens only
324
378
  // the pairing route, for that CLI. Never an environment variable: every
@@ -416,7 +470,7 @@ function messageSender(auth) {
416
470
  if (auth.kind !== "session")
417
471
  return undefined;
418
472
  const name = (auth.session.email ?? auth.session.label ?? "").trim();
419
- return name ? { name, id: personKey(auth.session) } : undefined;
473
+ return name ? { name, id: actorKey(auth) } : undefined;
420
474
  }
421
475
  /** An opaque, stable key for the person behind a session: their account
422
476
  * email when they signed in with one (a new device is still them), else the
@@ -474,7 +528,7 @@ function sharedComputerPrincipal(capability) {
474
528
  // their devices, or a routine they wrote. Never a guest, a webhook, a room
475
529
  // or a line someone else slipped in.
476
530
  if (CLOUD_HOME)
477
- return cloudHomeLendingRefusal(cloudLendingTurn(capability)) === null ? CLOUD_HOME_LENDER : null;
531
+ return cloudLendingRefusal(capability) === null ? CLOUD_HOME_LENDER : null;
478
532
  return provenRequestPerson(directRequestOwners.get(capability.threadId), capability.generation, (messageId) => linePersonKey(store.messagesFor(capability.threadId).find((message) => message.id === messageId)));
479
533
  }
480
534
  /** What the lending rule needs to know about one bot turn on a Cloud home. */
@@ -483,7 +537,9 @@ function cloudLendingTurn(capability) {
483
537
  request: directRequestOwners.get(capability.threadId),
484
538
  generation: capability.generation,
485
539
  thread: store.messagesFor(capability.threadId),
486
- ownerPerson: cloudOwnerPerson,
540
+ starters: cloudThreadStarters(capability.threadId),
541
+ reportsFromOthers: store.messagesFor(capability.threadId).some(cloudOthersRoutineReport),
542
+ ownerPerson: cloudProvenOwnerPerson,
487
543
  cardAnswerer: cloudCardAnswerer,
488
544
  routineRun: () => {
489
545
  const run = activeRoutineRunForThread(capability.threadId);
@@ -493,9 +549,7 @@ function cloudLendingTurn(capability) {
493
549
  triggerSource: run.triggerSource ?? (run.manual ? "manual" : "schedule"),
494
550
  ownerStarted: ownerStartedRoutineRuns.has(run.id),
495
551
  // What the run snapshotted, on the schedule its routine has now.
496
- ownerAuthored: ((routine) => Boolean(routine) && cloudRoutineAuthors?.authored(run.routineId, {
497
- ...routine, prompt: run.prompt, target: run.target, botId: run.botId, groupId: run.groupId, attachments: run.attachments, runOn: run.runOn,
498
- }) === true)(routines?.listRoutines().find((candidate) => candidate.id === run.routineId)),
552
+ ownerAuthored: cloudRoutineRunIsOwners(run, capability.botId, capability.threadId),
499
553
  };
500
554
  },
501
555
  };
@@ -503,6 +557,82 @@ function cloudLendingTurn(capability) {
503
557
  /** What a bot on a Cloud home is told when its conversation cannot use the
504
558
  * owner's lent Mac because someone else wrote in it. The bot relays it. */
505
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
+ }
506
636
  /** Whose lent computers a status reader may see: on a Cloud home, only the
507
637
  * owner's own devices (admin sessions) see the lent Mac, never a guest or a
508
638
  * local process; elsewhere, the person behind the asking session. */
@@ -511,6 +641,239 @@ function sessionLendingPrincipal(auth) {
511
641
  return cloudOwnerSession(auth) ? CLOUD_HOME_LENDER : null;
512
642
  return auth.kind === "session" ? personKey(auth.session) : null;
513
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
+ }
514
877
  /** On a Cloud home: a request from one of the owner's own devices. The
515
878
  * Admin's signed pairing gives the desktop an admin session; a guest the
516
879
  * owner pairs with chat-only access has client scope. Loopback is not the
@@ -519,10 +882,31 @@ function sessionLendingPrincipal(auth) {
519
882
  function cloudOwnerSession(auth) {
520
883
  return auth.kind === "session" && auth.scopes.includes("admin");
521
884
  }
522
- /** On a Cloud home: whether a message's person key is one of the owner's own
523
- * devices right now (a live admin session with that key). */
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). */
524
889
  function cloudOwnerPerson(person) {
525
- return Boolean(person) && sessions.list().some((session) => session.scopes.includes("admin") && personKey(session) === 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);
526
910
  }
527
911
  /** On a Cloud home, a session that is not one of the owner's own devices may
528
912
  * not answer a card of any kind. */
@@ -594,7 +978,7 @@ function cardAnswererFor(auth) {
594
978
  if (auth.kind === "loopback")
595
979
  return auth.trust === "service" ? { kind: "worker" } : { kind: "loopback" };
596
980
  return { kind: "session", name: (auth.session.email ?? auth.session.label ?? "").trim() || "Signed-in user",
597
- ...(CLOUD_HOME ? { person: personKey(auth.session) } : {}) };
981
+ ...(CLOUD_HOME ? { person: actorKey(auth) } : {}) };
598
982
  }
599
983
  /** On a Cloud home, who is answering a card right now, by request id: the
600
984
  * answer reaches the turn before `answeredBy` is written, and a lending turn
@@ -616,7 +1000,7 @@ async function answeringCardAs(auth, threadId, requestId, work) {
616
1000
  return Boolean(card && !card.answered && !card.dismissed && !card.expired);
617
1001
  })();
618
1002
  if (CLOUD_HOME && open)
619
- cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? personKey(auth.session) : undefined);
1003
+ cloudCardAnswersInFlight.set(requestId, auth.kind === "session" ? actorKey(auth) : undefined);
620
1004
  try {
621
1005
  await withDecisionActor(decisionActorFor(auth), work);
622
1006
  }
@@ -743,6 +1127,11 @@ function settleRoomFloor(group) {
743
1127
  return;
744
1128
  store.patchGroup(group.id, { audienceFloor: next === "everyone" ? undefined : next });
745
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
+ }
746
1135
  /** The conversations a bot's recent-work brief may draw on: its own, and the
747
1136
  * rooms roomFeedsBot allows. */
748
1137
  function recentWorkSources(bot) {
@@ -757,7 +1146,7 @@ function recentWorkSources(bot) {
757
1146
  * or a room must not be able to pull a private chat into its reply. The
758
1147
  * conversations are the ones session_search would search, minus this one. */
759
1148
  function autoRecallPrompt(bot, threadId, message, opts) {
760
- if (!autoRecallEnabled(cfg))
1149
+ if (bot.memoryEnabled === false || !autoRecallEnabled(cfg))
761
1150
  return "";
762
1151
  const roomByThread = new Map();
763
1152
  if (opts.conversations) {
@@ -770,7 +1159,10 @@ function autoRecallPrompt(bot, threadId, message, opts) {
770
1159
  }
771
1160
  }
772
1161
  const threadIds = opts.conversations
773
- ? [...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)))
774
1166
  : [];
775
1167
  try {
776
1168
  const recalled = buildRecall({
@@ -902,11 +1294,13 @@ function adminAuditPlan(method, path) {
902
1294
  m = /^\/api\/groups\/([\w-]+)$/.exec(path);
903
1295
  if (m && method === "PATCH")
904
1296
  plan.roomId = m[1];
905
- 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);
906
1298
  if (m && method === "POST")
907
1299
  plan.engineAction = { id: m[1], action: m[2].replace("/", "-") };
908
1300
  if (method === "POST" && path === "/api/instances/claude-accounts")
909
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" };
910
1304
  return plan.config || plan.botId || plan.createsBots || plan.deletesBot || plan.createsWebhook || plan.webhookId || plan.sessionId || plan.pairing || plan.engineAction || plan.roomId ? plan : null;
911
1305
  }
912
1306
  const WEBHOOK_AUDIT_FIELDS = ["name", "prompt", "botId", "runOn", "enabled", "eventTypes"];
@@ -1081,6 +1475,46 @@ function queuedTurnTrigger(item) {
1081
1475
  return item.sender ? { kind: "user", label: item.sender.name } : { kind: "owner" };
1082
1476
  }
1083
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
+ }
1084
1518
  await registry.load(providerConfigs(), decorateHostedProvider);
1085
1519
  const bundledSkills = loadBundledSkills();
1086
1520
  const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
@@ -1179,7 +1613,10 @@ let companyRuntimeReady;
1179
1613
  const companyRuntimeStarted = new Promise(resolve => { companyRuntimeReady = resolve; });
1180
1614
  /** The enrolled organisation's desktop policy: in memory only, read-only, and
1181
1615
  * inert (null) unless Electron's enrolled parent sends one. */
1182
- const managedPolicy = new ManagedDesktopPolicy({ onChange: () => broadcast({ kind: "config", ...configStatus() }) });
1616
+ const managedPolicy = new ManagedDesktopPolicy({ onChange: () => {
1617
+ broadcast({ kind: "config", ...configStatus() });
1618
+ resyncOpenCodeProviderKeys();
1619
+ } });
1183
1620
  /** A computer kind this server will not use, refused before anything is
1184
1621
  * prepared: a Cloud home never offers this computer or a Local VM
1185
1622
  * (cloud-home.ts), and an enrolled organisation may disallow any kind. */
@@ -1203,7 +1640,10 @@ const managedDesktop = new ManagedDesktopProviders({
1203
1640
  beforeReplace: stopCompanyInstances,
1204
1641
  // Ids became stable across re-enrolment; carry old references forward once.
1205
1642
  migrate: aliases => { store.renameInstances(new Map(aliases.map(({ from, to }) => [from, to]))); },
1206
- onAvailability: () => broadcast({ kind: "config", ...configStatus() }),
1643
+ onAvailability: () => {
1644
+ broadcast({ kind: "config", ...configStatus() });
1645
+ resyncOpenCodeProviderKeys();
1646
+ },
1207
1647
  afterReplace: ids => {
1208
1648
  for (const id of providerInstancesChanging)
1209
1649
  if (managedDesktop.owns(id))
@@ -1212,8 +1652,11 @@ const managedDesktop = new ManagedDesktopProviders({
1212
1652
  // Existing renderer config events refresh /api/instances as well, so
1213
1653
  // Company grants and revocations appear without reloading the window.
1214
1654
  broadcast({ kind: "config", ...configStatus() });
1655
+ resyncOpenCodeProviderKeys();
1215
1656
  },
1216
1657
  });
1658
+ // An organisation-managed desktop's environment is not the person's shell.
1659
+ openCodeOrganisationManaged = () => managedPolicy.current() !== null || managedDesktop.enrolled();
1217
1660
  utilityParentPort?.on("message", event => {
1218
1661
  const message = event.data;
1219
1662
  if (message?.type !== "openmausbot:managed-desktop-identity")
@@ -1336,7 +1779,27 @@ function mintInternalCapability(capability) {
1336
1779
  });
1337
1780
  return token;
1338
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
+ }
1339
1799
  function revokeInternalCapabilityGeneration(threadId, generation) {
1800
+ endForeignTurns(threadId, generation);
1801
+ if (guestDrivenTurns.get(threadId) === generation)
1802
+ guestDrivenTurns.delete(threadId);
1340
1803
  for (const [token, capability] of internalCapabilities) {
1341
1804
  if (capability.threadId === threadId && capability.generation === generation) {
1342
1805
  internalCapabilities.delete(token);
@@ -1363,6 +1826,9 @@ function revokeInternalCapabilitiesForThread(threadId) {
1363
1826
  function revokeAllInternalCapabilities() {
1364
1827
  computerSelectionTurns.clear();
1365
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).
1366
1832
  activeInternalGenerationByThread.clear();
1367
1833
  internalGenerationByProviderTurn.clear();
1368
1834
  }
@@ -1474,6 +1940,7 @@ function agentsIntegration(botId, threadId, depth, skillAuthoring, generation, r
1474
1940
  OMB_ROOM_TURN: roomCoordination ? "1" : "0",
1475
1941
  OMB_OWN_THREAD_CREATION: ownThreadCreation ? "1" : "0",
1476
1942
  OMB_SKILL_AUTHORING_ENABLED: skillAuthoring ? "1" : "0",
1943
+ OMB_MEMORY_ENABLED: store.bot(botId)?.memoryEnabled === false ? "0" : "1",
1477
1944
  // The shared-computer tools are advertised only while the workspace
1478
1945
  // gate is on; the routes behind them refuse regardless.
1479
1946
  OMB_SHARED_COMPUTERS_ENABLED: lendingEnabled() ? "1" : "0",
@@ -1902,7 +2369,15 @@ function canAdmitDirectTurn(botId, threadId) {
1902
2369
  /** Routine and webhook dispatch shares startTurn's admission preconditions
1903
2370
  * instead of waiting for whole-bot idleness: a free thread slot and no
1904
2371
  * active group turn. A group turn blocks scheduled starts the same way it
1905
- * blocks every other turn kind; it does not consume a capacity slot. */
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. */
1906
2381
  function unattendedDispatchState(botId) {
1907
2382
  const bot = store.bot(botId);
1908
2383
  const decision = admit("unattended", {}, {
@@ -1910,7 +2385,19 @@ function unattendedDispatchState(botId) {
1910
2385
  atCapacity: Boolean(bot) && botAtThreadCapacity(botId),
1911
2386
  groupTurn: Boolean(bot) && Boolean(activeGroupTurnForBot(botId)),
1912
2387
  });
1913
- 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";
1914
2401
  }
1915
2402
  function requestedTaskBot(botId, rawThreadId) {
1916
2403
  const profile = store.bot(botId);
@@ -2076,7 +2563,16 @@ function cancelDirectTurnDispatch(botId, expectedThreadId) {
2076
2563
  /** The bot's browser for this turn: agent-browser, one isolated session per
2077
2564
  * browser profile or per bot (docs/plans/browser-engine.md). Null, with the
2078
2565
  * reason logged once, when the engine is not on this machine. */
2079
- 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
+ });
2080
2576
  const browserLive = new BrowserLive({ runtime: browserRuntime });
2081
2577
  // Temporary profiles last for this server run, but are never saved to disk.
2082
2578
  // The viewer and the agent must address the SAME temporary browser.
@@ -2763,7 +3259,7 @@ function previewSystemPrompt(bot) {
2763
3259
  { id: "routine", label: "Routines", text: agentsMounted ? ROUTINE_PROMPT : "" },
2764
3260
  { id: "profile", label: "Profile changes", text: agentsMounted ? PROFILE_PROMPT : "" },
2765
3261
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
2766
- { 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 }) },
2767
3263
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
2768
3264
  ]);
2769
3265
  const totalBytes = built.sections.reduce((n, s) => n + s.bytes, 0);
@@ -2851,7 +3347,12 @@ async function botOverview(bot) {
2851
3347
  * with one deliberate exception: a Chief of Staff with Full access makes the
2852
3348
  * threads it delegates Full too (delegatedFullAccess), so the grant the
2853
3349
  * person gave the Chief covers the work the Chief hands out. */
2854
- 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";
2855
3356
  const mode = approvalModeForOrigin(approvalModeFor(bot), { peerInitiated });
2856
3357
  if (!supportsApprovalMode(bot.modelSelection, mode)) {
2857
3358
  return "ask";
@@ -2866,7 +3367,7 @@ function fullAccessForSource(botId, threadId) {
2866
3367
  return false;
2867
3368
  const bot = store.projectBotForTask(botId, threadId) ?? owner.bot;
2868
3369
  // Origin changes Custom to Auto, never Full; no live-turn state is needed.
2869
- return approvalModeForTurn(bot) === "full";
3370
+ return approvalModeForTurn(bot, false, threadId) === "full";
2870
3371
  }
2871
3372
  function peerReviewRequired(bot, threadId) {
2872
3373
  return Boolean(bot.approvePeerComms && !fullAccessForSource(bot.id, threadId));
@@ -2890,8 +3391,7 @@ function delegatedFullAccess(from, fromThreadId, target) {
2890
3391
  }
2891
3392
  /** Make a delegated thread Full and say so in it once, so the level the
2892
3393
  * chip shows and the level the turns run at agree, and the person can see
2893
- * where the access came from. Idempotent: a pair conversation is reused
2894
- * across delegations and must not collect a chip per request. */
3394
+ * where the access came from. */
2895
3395
  function grantDelegatedFullAccess(from, target, threadId) {
2896
3396
  if (store.taskByThread(target.id, threadId)?.approvalMode === "full")
2897
3397
  return;
@@ -2905,13 +3405,16 @@ function grantDelegatedFullAccess(from, target, threadId) {
2905
3405
  /** A room member's level for one turn. Work a Full-access Chief hands out
2906
3406
  * in a room runs Full for that turn: the room thread is shared, so the
2907
3407
  * level is not stored on it — it rides the handoff. */
2908
- 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";
2909
3412
  const handoff = orchestration?.roomHandoffId ? roomHandoffs.nodes.get(orchestration.roomHandoffId) : undefined;
2910
3413
  const source = handoff?.parentId ? roomHandoffs.nodes.get(handoff.parentId) : undefined;
2911
3414
  const from = source ? store.bot(source.botId) : undefined;
2912
3415
  if (from && source && delegatedFullAccess(from, source.threadId, bot))
2913
3416
  return "full";
2914
- return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId));
3417
+ return approvalModeForTurn(bot, Boolean(orchestration?.roomHandoffId), threadId);
2915
3418
  }
2916
3419
  /** Privileged approval-mode transitions are deliberately absent from the
2917
3420
  * loopback HTTP authority model: a bot with shell access can curl that
@@ -3668,6 +4171,10 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3668
4171
  : child.status === "completed" ? `${bot?.name ?? "Teammate"} replied${child.groupId ? ` · ${store.group(child.groupId)?.name ?? "Room"}` : ""}`
3669
4172
  : `${bot?.name ?? "Teammate"} — ${child.status}: ${child.result.slice(0, 180)}`,
3670
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) } : {}),
3671
4178
  },
3672
4179
  ...(child.groupId ? { comm: { groupId: child.groupId, threadId: child.threadId, withBotId: child.botId,
3673
4180
  withName: bot?.name ?? "Teammate", withColor: bot?.color ?? "blue" } }
@@ -3681,14 +4188,9 @@ const roomHandoffs = new RoomHandoffs(join(DATA_DIR, "room-handoffs.json"), {
3681
4188
  if (source)
3682
4189
  markTaskContextExternallyUpdated(source, parent.threadId);
3683
4190
  }
3684
- // A work thread exists only because the pair conversation was busy with
3685
- // another job. Its result is now in the sender's conversation, so it
3686
- // closes itself exactly as close_thread would — folded out of the
3687
- // sidebar, never deleted, and open again the moment anyone speaks
3688
- // there. A finished job tidies up after itself; a failed or withheld
3689
- // one stays in the sidebar where the person can see it. The pair
3690
- // conversation is the standing line between two bots and never
3691
- // auto-closes.
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.
3692
4194
  const childTask = store.taskByThread(child.botId, child.threadId);
3693
4195
  if (!child.groupId && child.status === "completed" && !problem && !childTask?.closedBy
3694
4196
  && childTask?.openedBy?.kind === "work" && childTask.openedBy.botId === parent.botId) {
@@ -4337,8 +4839,35 @@ function audienceChanged() {
4337
4839
  }
4338
4840
  }
4339
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
+ });
4340
4868
  function closeSessionStreams(sessionId) {
4341
4869
  browserLive.closeForOwner(sessionId);
4870
+ desktopViewer.closeForOwner(sessionId);
4342
4871
  for (const client of sseClients) {
4343
4872
  if (client.sessionId !== sessionId)
4344
4873
  continue;
@@ -4353,8 +4882,35 @@ function closeSessionStreams(sessionId) {
4353
4882
  }
4354
4883
  sessions.onSessionRevoked((sessionId) => {
4355
4884
  providerAuthSessions.revokeOwner(sessionId);
4885
+ mcpOAuth.revokeOwner(sessionId);
4356
4886
  closeSessionStreams(sessionId);
4887
+ // A revoked owner device no longer vouches for its lines (cloudOwnerPerson).
4888
+ ownerOnlyCache.clear();
4357
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
+ }
4358
4914
  /** Every frame is numbered, and the last few hundred are kept, so a client
4359
4915
  * whose connection dropped can ask for what it missed instead of
4360
4916
  * re-downloading every transcript. The desktop reconnects in milliseconds
@@ -4827,10 +5383,34 @@ const memoryUpkeep = createMemoryUpkeep({
4827
5383
  return facts.length;
4828
5384
  },
4829
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) } : {}),
4830
5389
  quietMs: () => captureQuietMs(cfg),
4831
5390
  tidyHour: () => tidyHour(cfg),
4832
5391
  log: (line) => console.log(line),
4833
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
+ });
4834
5414
  // A finished 1:1 turn of an upkeep bot waits for capture. Only the person's
4835
5415
  // own conversation: not a room, not a turn another bot or the harness
4836
5416
  // started (its "user" line is not the person), not a failed turn.
@@ -4844,6 +5424,10 @@ bus.subscribe((event) => {
4844
5424
  if (!bot || !upkeepEnabled(bot) || store.groupByThread(event.threadId) || isInternalTurn(event.threadId)
4845
5425
  || isUnattended(bot.id, event.threadId) || routines?.runForThread(event.threadId))
4846
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;
4847
5431
  const path = store.activePath(event.threadId);
4848
5432
  const replyAt = path.findLastIndex((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
4849
5433
  if (replyAt < 0)
@@ -4876,7 +5460,7 @@ bus.subscribe((event) => {
4876
5460
  const messages = store.messagesFor(event.threadId);
4877
5461
  const reply = messages.findLast((message) => message.role === "bot" && message.kind === "text" && message.turnId === event.turnId);
4878
5462
  const bot = store.botByThread(event.threadId) ?? (reply?.from ? store.bot(reply.from.botId) : undefined);
4879
- if (!bot)
5463
+ if (!bot || bot.memoryEnabled === false)
4880
5464
  return;
4881
5465
  const tools = messages
4882
5466
  .filter((message) => message.kind === "activity" && message.turnId === event.turnId && message.tool?.name)
@@ -4889,13 +5473,21 @@ bus.subscribe((event) => {
4889
5473
  const room = store.groupByThread(event.threadId);
4890
5474
  if (room && !roomFeedsBot(room, bot))
4891
5475
  return;
4892
- 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, {
4893
5481
  source: memorySourceLabel({
4894
5482
  room,
4895
5483
  task: store.taskByThread(bot.id, event.threadId),
4896
5484
  threadId: event.threadId,
4897
5485
  }),
4898
5486
  });
5487
+ if (lendingMemory)
5488
+ lendingMemory.trustedWrite(bot.id, writeLine);
5489
+ else
5490
+ writeLine();
4899
5491
  }
4900
5492
  catch {
4901
5493
  // a missing note never fails a turn
@@ -5180,13 +5772,18 @@ const boatLifecycleBusyBots = new Set();
5180
5772
  // A refresh is a reader, not a lifecycle change. Keep its reservation until
5181
5773
  // the provider settles even if the HTTP client leaves, and share it on retry.
5182
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();
5183
5778
  const orphanBoatLifecycleBusyIds = new Set();
5184
5779
  const boatInventoryRequestsBusyIds = new Set();
5185
5780
  const computerProviderConfigTransitions = new Set();
5186
5781
  // A restore mutates and cleans a project work tree. Claim the bot across the
5187
5782
  // entire async Git operation so a turn cannot start in that folder midway.
5188
5783
  const checkpointRestoreLeases = new Set();
5189
- 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;
5190
5787
  /** How long a turn waits for Cua Driver after starting the container itself.
5191
5788
  * A cold XFCE desktop needs some seconds; past this the turn reports the
5192
5789
  * status it has rather than hanging on a container that will not come up. */
@@ -5317,7 +5914,7 @@ async function teamComputersPayload() {
5317
5914
  * Assignment authorizes waking, never replacing a missing paid machine. */
5318
5915
  async function attachTeamBoat(computer, botId, owner, canMount, remoteAgent) {
5319
5916
  if (!canMount)
5320
- 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");
5321
5918
  if (!boat.boatConfigured(cfg))
5322
5919
  throw new Error("The team's Boat account is not configured; reconnect it in Settings");
5323
5920
  const ownerId = teamComputerOwner(computer.id);
@@ -5353,7 +5950,7 @@ async function mountHostComputer(owner, botId, providerSupportsLocal) {
5353
5950
  // ACP engine" while already on one has nowhere to go.
5354
5951
  throw new Error(providerSupportsLocal
5355
5952
  ? `local computer control is not available on ${process.platform} — select another destination`
5356
- : "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");
5357
5954
  }
5358
5955
  const cua = readCuaConnection();
5359
5956
  if (!cua) {
@@ -5626,7 +6223,7 @@ async function selectableComputers(bot) {
5626
6223
  }
5627
6224
  else if (surface === "browser") {
5628
6225
  ready = caps?.browserMcp === true && builtInBrowserEnabled(cfg) && bot.browser !== false && browserEngineStatus().kind === "ready";
5629
- reason = "The built-in browser is disabled, not installed, or unsupported by this model engine.";
6226
+ reason = "The built-in browser is disabled, not installed, or unsupported by this model.";
5630
6227
  }
5631
6228
  }
5632
6229
  catch (error) {
@@ -5777,7 +6374,7 @@ function localVmIdleFor(target) {
5777
6374
  let idle = localVmIdles.get(target.key);
5778
6375
  if (idle)
5779
6376
  return idle;
5780
- 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 () => {
5781
6378
  localVmLifecycleBusy.add(target.key);
5782
6379
  try {
5783
6380
  const status = await containerComputerStatus(undefined, undefined, target);
@@ -6118,10 +6715,13 @@ bus.subscribe((event) => {
6118
6715
  // always reaches the human — even Full access never invents an answer.
6119
6716
  const asker = bot ?? (speaker ? store.bot(speaker.botId) : undefined);
6120
6717
  const unattended = permission && asker && event.requestId ? isUnattended(asker.id, event.threadId) : false;
6121
- 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";
6122
6722
  // Native shell descriptors are distinct from computer/MCP permissions;
6123
6723
  // choosing a local desktop must not disable an exact shell grant.
6124
- const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command
6724
+ const command = permission && asker && event.requestId && !event.requiresExplicitApproval && event.command && !guestDriven
6125
6725
  ? commandAllowlistCandidate({ ...event.command, providerInstanceId: event.providerInstanceId ?? asker.modelSelection.instanceId })
6126
6726
  : null;
6127
6727
  const verdict = permission && asker && event.requestId
@@ -6226,7 +6826,8 @@ bus.subscribe((event) => {
6226
6826
  : undefined,
6227
6827
  commandAllowlist: command ?? undefined,
6228
6828
  // Provider-owned session grants remain separate from exact commands.
6229
- 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,
6230
6831
  // The text stays for cards saved before heldCode existed, and for
6231
6832
  // clients that do not know the key yet.
6232
6833
  held: approvalHeldReason(heldContext),
@@ -6304,12 +6905,19 @@ bus.subscribe((event) => {
6304
6905
  tool: { name: `retrying — attempt ${event.attempt + 1}/${RETRY_MAX_ATTEMPTS} in ${Math.round(event.delayMs / 1000)}s — ${event.reason}`, ok: true },
6305
6906
  });
6306
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;
6307
6914
  case "runtime.error":
6308
6915
  pushMessage({
6309
6916
  role: "bot",
6310
6917
  kind: "activity",
6311
6918
  tool: {
6312
- 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)}`,
6313
6921
  ok: false,
6314
6922
  setup: event.setup,
6315
6923
  ...(event.terminal ? { terminal: true } : {}),
@@ -7554,14 +8162,10 @@ async function finalScreenFrame(_botId, threadId) {
7554
8162
  * (generateText — Haiku on Claude, the chat completion endpoint's text
7555
8163
  * path on OpenAI-compatible engines). Null whenever that call cannot run,
7556
8164
  * runs long, or answers with something that is not a plain short title;
7557
- * the caller keeps the snippet it already applied. */
7558
- async function generateThreadTitle(provider, text) {
7559
- const prompt = [
7560
- "Name the conversation that begins with the message below.",
7561
- "Reply with only a short title: 3 to 6 words, plain text, no quotes, no trailing period.",
7562
- "Message:",
7563
- text.trim().slice(0, 1_500),
7564
- ].join("\n");
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);
7565
8169
  const expiry = new AbortController();
7566
8170
  let timer;
7567
8171
  try {
@@ -7585,6 +8189,8 @@ async function generateThreadTitle(provider, text) {
7585
8189
  clearTimeout(timer);
7586
8190
  }
7587
8191
  }
8192
+ /** Threads whose Regenerate title one-shot is still running: one at a time. */
8193
+ const titleRegenerations = new Set();
7588
8194
  // ── turn dispatch (upstream ProviderCommandReactor, miniature) ──────────
7589
8195
  async function startTurn(botId, text, opts) {
7590
8196
  workspaceMaintenance.assertAvailable();
@@ -7671,6 +8277,15 @@ async function startTurn(botId, text, opts) {
7671
8277
  const policyRefusal = policyModelRefusal(instance);
7672
8278
  if (policyRefusal)
7673
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
+ }
7674
8289
  // Resolve only transport tags from this newly submitted text. The original
7675
8290
  // string remains the durable message. Native-image providers get a
7676
8291
  // path-free prompt and bounded inputs instead of needing a Read tool;
@@ -7691,11 +8306,9 @@ async function startTurn(botId, text, opts) {
7691
8306
  const titled = store.titleTaskFromFirstMessage(bot.id, resolvedImages.text, threadId);
7692
8307
  // The snippet is only the fallback name. A cheap one-shot may trade it
7693
8308
  // for a title a person would have typed, but never on a peer-opened
7694
- // row: adoption recognises those by the exact title their assignment
7695
- // gave them (openingRequestTitle), and a generated one would break the
7696
- // comparison it renames under. Everywhere else, the swap happens only
7697
- // while the row still carries the snippet — a rename by the person or
7698
- // 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.
7699
8312
  const snippet = titled?.title;
7700
8313
  if (titled && snippet && !titled.openedBy?.botId && llmThreadTitlesEnabled(cfg) && instance.generateText) {
7701
8314
  void generateThreadTitle(instance, resolvedImages.text)
@@ -7772,6 +8385,9 @@ async function startTurn(botId, text, opts) {
7772
8385
  // in the background — boat provisioning can take ~90s and must never
7773
8386
  // hang the HTTP request
7774
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);
7775
8391
  const resourceOwner = { threadId, generation: dispatchClaimId };
7776
8392
  turnResourceOwners.set(threadId, resourceOwner);
7777
8393
  directTurnGenerationByThread.set(threadId, dispatchClaimId);
@@ -7786,6 +8402,10 @@ async function startTurn(botId, text, opts) {
7786
8402
  requestGenerations.add(dispatchClaimId);
7787
8403
  directRequestOwners.set(threadId, { generation: dispatchClaimId, messageId: requestMessageId,
7788
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);
7789
8409
  // An unknown control-plane wake may invalidate a prior final, but it must
7790
8410
  // never acquire authority by guessing the latest user as its origin.
7791
8411
  const pendingSource = requestMessageId ?? store.activePath(threadId).findLast(message => message.role === "user")?.id;
@@ -7911,9 +8531,9 @@ async function startTurn(botId, text, opts) {
7911
8531
  const externalUpdate = Boolean(opts?.coordination?.resumed || unseen?.some((m) => m.keep));
7912
8532
  // What a resumed session keeps from its launch: the standing instructions
7913
8533
  // (tools, servers and — for Claude — the model are passed on every launch),
7914
- // plus whatever this engine can only set when a session starts. Codex's
7915
- // thread/resume sends no model selection, and an effort it is not sent stays
7916
- // 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
7917
8537
  // external update that finds any of it changed since the session started
7918
8538
  // gets the fresh session and replay it always got, rather than a resume.
7919
8539
  const persistentConfig = [bot.name, bot.title, bot.description, sectionContextSystemPrompt(bot.section),
@@ -8014,7 +8634,7 @@ async function startTurn(botId, text, opts) {
8014
8634
  // composio — only to a driver that can mount them. Their tools are
8015
8635
  // never pre-allowed, so every call rides the normal permission flow.
8016
8636
  if (instance.adapter.capabilities.customMcp === true) {
8017
- const custom = engineMcpServers(bot);
8637
+ const custom = await withMcpSignIn(engineMcpServers(bot), mcpOAuth);
8018
8638
  if (Object.keys(custom).length)
8019
8639
  integrations.custom = custom;
8020
8640
  }
@@ -8026,7 +8646,8 @@ async function startTurn(botId, text, opts) {
8026
8646
  if (worksInWorkspace) {
8027
8647
  ensureWorkspace(bot.id);
8028
8648
  // baseline for the journal's turn-boundary diff (see the bus hook)
8029
- beginMemoryTurn(bot.id, threadId);
8649
+ if (bot.memoryEnabled !== false)
8650
+ beginMemoryTurn(bot.id, threadId);
8030
8651
  }
8031
8652
  const privateWorkspace = worksInWorkspace ? ensureTaskWorkspace(bot.id, threadId) : undefined;
8032
8653
  const skillInstructions = renderSkillInstructions(selectedSkills, {
@@ -8042,8 +8663,13 @@ async function startTurn(botId, text, opts) {
8042
8663
  // bot's folder for a task that runs elsewhere.
8043
8664
  if (opts?.runOn === "cloud")
8044
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).
8045
8671
  const pinnedCwd = privateWorkspace && opts?.runOn !== "cloud"
8046
- ? store.pinTaskCwd(bot.id, threadId, privateWorkspace)
8672
+ ? store.pinTaskCwd(bot.id, threadId, privateWorkspace, { privateOnly: cloudGuestOpened(threadId) })
8047
8673
  : null;
8048
8674
  const cwd = pinnedCwd ?? undefined;
8049
8675
  if (cwd && !claimTurnResource(resourceOwner, workspaceResource(cwd))) {
@@ -8239,7 +8865,7 @@ async function startTurn(botId, text, opts) {
8239
8865
  if (!mountsComputerMcp || instance.adapter.capabilities.remoteAgent === true) {
8240
8866
  if (!strict)
8241
8867
  return false;
8242
- 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");
8243
8869
  }
8244
8870
  if (!strict && localVmMode(cfg) === "pool") {
8245
8871
  // localVmTargetForThread records pool affinity, so a turn the
@@ -8455,9 +9081,9 @@ async function startTurn(botId, text, opts) {
8455
9081
  autoVpsProblem &&
8456
9082
  !computerSelectionTurns.has(threadId)) {
8457
9083
  const hint = opts?.automationSource
8458
- ? "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."
8459
9085
  : bot.autoStartVps
8460
- ? "Check the VPS connection in App Settings → Connections."
9086
+ ? "Check the VPS connection in Settings → API keys."
8461
9087
  : "Open Computer and enable Start VPS automatically, or choose Cloud to start it manually.";
8462
9088
  throw new Error(`${autoVpsProblem}. ${hint}`);
8463
9089
  }
@@ -8503,7 +9129,7 @@ async function startTurn(botId, text, opts) {
8503
9129
  const credentialPrompt = integrations.agents ? CREDENTIAL_PROMPT + (boundedCoordination ? "" : THREADS_PROMPT) : "";
8504
9130
  const routinePrompt = integrations.agents ? ROUTINE_PROMPT : "";
8505
9131
  const profilePrompt = integrations.agents ? PROFILE_PROMPT : "";
8506
- const recallPrompt = integrations.agents ? SESSION_SEARCH_SYSTEM_PROMPT : "";
9132
+ const recallPrompt = integrations.agents && bot.memoryEnabled !== false ? SESSION_SEARCH_SYSTEM_PROMPT : "";
8507
9133
  const learnPrompt = skillAuthoring ? LEARN_PROMPT : "";
8508
9134
  // (activeVpsThreads was already claimed above, before the provision or
8509
9135
  // reuse await, so the backend guards saw this turn the whole time.)
@@ -8636,8 +9262,8 @@ async function startTurn(botId, text, opts) {
8636
9262
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
8637
9263
  // what the bot said lately in its other conversations, so a task
8638
9264
  // never redoes — or forgets — what another one already did
8639
- { id: "recent", label: "Recent work", text: recentWorkPrompt(recentWork(recentWorkSources(bot), bot, { userName: cfg.profile?.name?.trim() || "User", currentThreadId: threadId })) },
8640
- { id: "memory", label: "Memory", text: memorySystemPrompt(bot.id, { managedWrites: Boolean(integrations.agents), fileTools: worksInWorkspace }) },
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 }) },
8641
9267
  { id: "skills", label: "Skills index", text: privateWorkspace ? skillsSystemPrompt(bot.id) : "" },
8642
9268
  { id: "skill-instructions", label: "Skill instructions", text: skillInstructions },
8643
9269
  { id: "playbooks", label: "Playbooks", text: packagePlaybooks },
@@ -8681,7 +9307,8 @@ async function startTurn(botId, text, opts) {
8681
9307
  text: withRecalled(recalled, dispatchContext.turnText),
8682
9308
  refreshSystemPrompt: true,
8683
9309
  images: turnImages,
8684
- approvalMode: approvalModeForTurn(bot, commsDepth > 0),
9310
+ approvalMode: approvalModeForTurn(bot, commsDepth > 0, threadId),
9311
+ ...(guestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
8685
9312
  model,
8686
9313
  effort,
8687
9314
  variant,
@@ -8972,7 +9599,10 @@ function routineRunFallbackText(card) {
8972
9599
  : card.status === "queued" && card.deferredAt != null
8973
9600
  ? "deferred: target busy"
8974
9601
  : card.status);
8975
- 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}` : ""}`;
8976
9606
  }
8977
9607
  /** Upsert one durable lifecycle card per run. Replaying the same transition,
8978
9608
  * including restart recovery, patches the existing run id instead of adding
@@ -9017,9 +9647,14 @@ function syncRoutineRunToSource(run) {
9017
9647
  const execution = store.taskByThread(run.botId, run.threadId);
9018
9648
  const freshExecution = execution && run.status === "running" && !execution.routineRunId &&
9019
9649
  store.messagesFor(run.threadId).length === 0;
9020
- if (execution && (freshExecution || (execution.routineRunId === run.id && execution.unread))) {
9650
+ if (execution && freshExecution) {
9021
9651
  store.patchTask(run.botId, run.threadId, { routineRunId: run.id, unread: false });
9022
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
+ }
9023
9658
  }
9024
9659
  // Merely queueing/running is ambient progress. Attention and terminal
9025
9660
  // states become unread in the conversation where the user asked for them.
@@ -9111,8 +9746,13 @@ routines = new RoutineManager({
9111
9746
  }
9112
9747
  return groupIsWorking(group) || coordinator.busy ? "busy" : "ready";
9113
9748
  },
9114
- createTask: (botId, title, activate = false) => {
9749
+ createTask: (botId, title, activate = false, routineId) => {
9115
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));
9116
9756
  const bot = store.bot(botId);
9117
9757
  if (task && bot)
9118
9758
  broadcast({ kind: "bot", bot: publicBot(bot) });
@@ -9140,7 +9780,12 @@ routines = new RoutineManager({
9140
9780
  // explicit resultsThreadId must be a visible task owned by the running bot.
9141
9781
  if (!forceNew && routineSourceOwner(routine))
9142
9782
  return routine.resultsThreadId;
9143
- 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;
9144
9789
  },
9145
9790
  discardResultsThread: (botId, threadId) => {
9146
9791
  const task = store.taskByThread(botId, threadId);
@@ -9202,6 +9847,63 @@ routines = new RoutineManager({
9202
9847
  notify(buildNotification("routine-deferred", notificationBot, routineSourceThread(run) ?? bot.threadId, detail));
9203
9848
  },
9204
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
+ }
9205
9907
  orgLibrary = new OrgLibrary({
9206
9908
  dataDir: DATA_DIR,
9207
9909
  store,
@@ -9276,7 +9978,7 @@ async function cloudRoutineReadiness(botId, threadId) {
9276
9978
  return { ready: false, reason: "The Cloud VM runner is unavailable. Restart OpenMausBot and try again." };
9277
9979
  try {
9278
9980
  if ((await instance.snapshot()).state !== "available") {
9279
- 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." };
9280
9982
  }
9281
9983
  const teamComputer = inheritedTeamComputer(bot);
9282
9984
  const ownerId = teamComputer ? teamComputerOwner(teamComputer.id) : bot.id;
@@ -9829,9 +10531,15 @@ function sendRoutineResolution(res, result) {
9829
10531
  });
9830
10532
  return true;
9831
10533
  }
9832
- function resolveAndSendRoutine(res, args) {
10534
+ function resolveAndSendRoutine(res, args, ownersAnswer = false) {
9833
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;
9834
10540
  const result = routineRequests.resolve(args);
10541
+ if (ownersAnswer && result.claimed && result.state === "applied")
10542
+ cloudOwnerApplied(result.action, result.resultId, wasOwners);
9835
10543
  if (result.claimed &&
9836
10544
  (result.state === "applied" || result.state === "denied")) {
9837
10545
  appendDecision(DATA_DIR, {
@@ -9967,12 +10675,25 @@ const webhooks = new WebhookManager({
9967
10675
  findRun: (webhookId, deliveryId) => routines.webhookRunReceipt(webhookId, deliveryId),
9968
10676
  cancelQueued: (webhookId, message) => routines.cancelQueuedWebhook(webhookId, message),
9969
10677
  pendingRuns: (webhookId) => routines.activeWebhookRunCount(webhookId),
9970
- // delivery:"post" webhooks land in the bot's main chat as the bot's own message.
9971
- post: (botId, text) => {
9972
- const bot = store.bot(botId);
9973
- 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))
9974
10686
  return;
9975
- 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;
9976
10697
  },
9977
10698
  });
9978
10699
  let webhookIngress = null;
@@ -10144,7 +10865,7 @@ setupRetry = 0) {
10144
10865
  // delegated Full elevation makes the two disagree by construction: every
10145
10866
  // such room turn then reads as "settings changed", retries once, and
10146
10867
  // settles as busy without ever dispatching.
10147
- const preparedApprovalMode = roomTurnApprovalMode(bot, orchestration);
10868
+ const preparedApprovalMode = roomTurnApprovalMode(bot, threadId, orchestration);
10148
10869
  const preparedSelection = { ...bot.modelSelection };
10149
10870
  const preparedComposio = bot.composio;
10150
10871
  const instance = turnInstance(bot);
@@ -10154,8 +10875,14 @@ setupRetry = 0) {
10154
10875
  return true;
10155
10876
  }
10156
10877
  const roomPolicyRefusal = instance ? policyModelRefusal(instance) : undefined;
10157
- if (!instance || roomPolicyRefusal) {
10158
- 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`;
10159
10886
  store.appendMessage(threadId, {
10160
10887
  role: "bot",
10161
10888
  kind: "activity",
@@ -10188,6 +10915,11 @@ setupRetry = 0) {
10188
10915
  return true;
10189
10916
  }
10190
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);
10191
10923
  const resourceOwner = { threadId, generation: internalGeneration };
10192
10924
  turnResourceOwners.set(threadId, resourceOwner);
10193
10925
  let roomVmTarget = null;
@@ -10294,7 +11026,7 @@ setupRetry = 0) {
10294
11026
  }
10295
11027
  // user-configured MCP servers: same gating as the 1:1 site above.
10296
11028
  if (instance.adapter.capabilities.customMcp === true) {
10297
- const custom = engineMcpServers(bot);
11029
+ const custom = await withMcpSignIn(engineMcpServers(bot), mcpOAuth);
10298
11030
  if (Object.keys(custom).length)
10299
11031
  integrations.custom = custom;
10300
11032
  }
@@ -10316,7 +11048,7 @@ setupRetry = 0) {
10316
11048
  if (!readyGroup || !stillOwnsThread || !readyGroup.memberIds.includes(readyBot.id))
10317
11049
  return false;
10318
11050
  const setupChanged = turnInstance(readyBot) !== instance ||
10319
- roomTurnApprovalMode(readyBot, orchestration) !== preparedApprovalMode ||
11051
+ roomTurnApprovalMode(readyBot, threadId, orchestration) !== preparedApprovalMode ||
10320
11052
  readyBot.modelSelection.instanceId !== preparedSelection.instanceId ||
10321
11053
  readyBot.modelSelection.model !== preparedSelection.model ||
10322
11054
  readyBot.modelSelection.effort !== preparedSelection.effort ||
@@ -10520,7 +11252,7 @@ setupRetry = 0) {
10520
11252
  // Claim the same lease as direct turns before asynchronous VM setup.
10521
11253
  if (readyBot.computer === "vm") {
10522
11254
  if (instance.adapter.capabilities.computerMcp !== true || instance.adapter.capabilities.remoteAgent === true) {
10523
- throw new Error("this model engine cannot use the Local VM");
11255
+ throw new Error("this model cannot use the Local VM");
10524
11256
  }
10525
11257
  // A distinct identity fences cleanup even in shared mode on the same room thread.
10526
11258
  const target = { ...localVmTargetForThread(readyBot.id, threadId) };
@@ -10613,7 +11345,7 @@ setupRetry = 0) {
10613
11345
  const worksInWorkspace = supportsWorkspaceFiles(instance.driverKind);
10614
11346
  const workspace = worksInWorkspace ? ensureWorkspace(bot.id) : undefined;
10615
11347
  // a room member's memory writes are journaled the same as a 1:1 turn's
10616
- if (workspace)
11348
+ if (workspace && bot.memoryEnabled !== false)
10617
11349
  beginMemoryTurn(bot.id, threadId);
10618
11350
  // The room's folder pins here — on the first turn that actually
10619
11351
  // dispatches, not at PATCH time — so a folder set on a never-used room
@@ -10621,19 +11353,22 @@ setupRetry = 0) {
10621
11353
  // has its folder moved underneath it. Off-host members skip the folder
10622
11354
  // but must not decide the pin: the room's desk is a property of the
10623
11355
  // room, not of whichever member happened to speak first.
10624
- 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));
10625
11360
  {
10626
11361
  const drift = checkSoulDrift(bot.id, bot.soul ?? "", bot.soulHash ?? "");
10627
11362
  if (drift.drift !== Boolean(bot.soulDrift))
10628
11363
  store.patchBot(bot.id, { soulDrift: drift.drift });
10629
11364
  }
10630
- 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 });
10631
11366
  // The same brief a 1:1 turn gets: what this member said lately in its
10632
11367
  // other conversations, so a standup is answered from what happened. A
10633
11368
  // brief can carry a private chat into the room; as with session_search
10634
11369
  // (#754) the room is told, once per source thread, rather than the
10635
11370
  // crossing being blocked.
10636
- const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId });
11371
+ const recentLines = recentWork(recentWorkSources(bot), bot, { userName, currentThreadId: threadId, ...recentWorkFilter() });
10637
11372
  const roomComputerPromptKind = resolveComputerPromptKind({
10638
11373
  kind: roomTeamComputer || roomComputerKind === "box" ? "box" : roomVmTarget ? "vm" : roomComputerKind,
10639
11374
  driverKind: instance.driverKind, cloudComputerMcp: instance.adapter.capabilities.cloudComputerMcp,
@@ -10659,7 +11394,7 @@ setupRetry = 0) {
10659
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) }) },
10660
11395
  { id: "cloud-home", label: "OMB Cloud", text: CLOUD_HOME ? cloudHomePrompt(Boolean(integrations.agents) && lendingEnabled()) : "" },
10661
11396
  { id: "browser", label: "Browser", text: integrations.browser ? BUILT_IN_BROWSER_SYSTEM_PROMPT : "" },
10662
- { 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 : "" },
10663
11398
  { id: "recent", label: "Recent work", text: recentWorkPrompt(recentLines) },
10664
11399
  { id: "section-context", label: "Section context", text: sectionContextSystemPrompt(bot.section) },
10665
11400
  // the room path has always put a newline before memory and trimmed
@@ -10794,7 +11529,8 @@ setupRetry = 0) {
10794
11529
  text: withRecalled(roomRecalled, text),
10795
11530
  refreshSystemPrompt: true,
10796
11531
  images: turnImages,
10797
- approvalMode: roomTurnApprovalMode(readyBot, orchestration),
11532
+ approvalMode: roomTurnApprovalMode(readyBot, threadId, orchestration),
11533
+ ...(roomGuestConfined ? { guestConfined: true, confinedWhy: confinedWhy(threadId) } : {}),
10798
11534
  system: roomSystem.text,
10799
11535
  systemStable: roomSystem.stable,
10800
11536
  systemVolatile: roomSystem.volatile,
@@ -12443,12 +13179,12 @@ function stderrOf(err) {
12443
13179
  const s = err.stderr;
12444
13180
  return typeof s === "string" ? s : Buffer.isBuffer(s) ? s.toString("utf8") : "";
12445
13181
  }
12446
- async function localVmPayload(target) {
13182
+ async function localVmPayload(target, auth) {
12447
13183
  const status = await containerComputerStatus(undefined, undefined, target);
12448
13184
  return {
12449
- ...status,
13185
+ ...localVmViewerStatus(status, auth),
12450
13186
  commands: setupCommands(status.runtime, process.platform, target),
12451
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
13187
+ idle_timeout_ms: localVmIdleMs(),
12452
13188
  mode: localVmMode(cfg),
12453
13189
  max_instances: localVmMaxInstances(cfg),
12454
13190
  };
@@ -12576,7 +13312,9 @@ function configStatus() {
12576
13312
  return {
12577
13313
  xai: { configured: Boolean(cfg.xai?.key) },
12578
13314
  mistral: { configured: Boolean(cfg.mistral?.key) },
12579
- 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) },
12580
13318
  // a fleet agent on this server means Settings → Workspaces has something to drive
12581
13319
  fleet: { available: fleetAvailable(fleetSocketPath()) },
12582
13320
  // what this build is entitled to, so Settings shows only what works here
@@ -12627,6 +13365,7 @@ function configStatus() {
12627
13365
  localVm: {
12628
13366
  mode: localVmMode(cfg),
12629
13367
  maxInstances: localVmMaxInstances(cfg),
13368
+ idleTimeoutMinutes: localVmIdleTimeoutMinutes(cfg),
12630
13369
  },
12631
13370
  features: {
12632
13371
  skillAuthoring: skillAuthoringEnabled(cfg),
@@ -12641,6 +13380,8 @@ function configStatus() {
12641
13380
  // own Claude Code MCP servers
12642
13381
  claudeUserMcp: claudeUserMcpEnabled(cfg),
12643
13382
  autoRecall: autoRecallEnabled(cfg),
13383
+ // hand-edited config.json only; the thread menu offers Regenerate title from it
13384
+ llmThreadTitles: llmThreadTitlesEnabled(cfg),
12644
13385
  },
12645
13386
  // first-run progress — not a secret; the app decides whether to show
12646
13387
  // the welcome tour from this, never from browser storage
@@ -12649,6 +13390,7 @@ function configStatus() {
12649
13390
  version: cfg.onboarding?.version ?? 0,
12650
13391
  reelSeen: cfg.onboarding?.reelSeen === true,
12651
13392
  hintsSeen: cfg.onboarding?.hintsSeen ?? [],
13393
+ ...(cfg.onboarding?.firstTurnAt ? { firstTurnAt: cfg.onboarding.firstTurnAt } : {}),
12652
13394
  },
12653
13395
  // Which browser this server can give bots: the desktop app's surface,
12654
13396
  // the agent-browser engine, or nothing yet (with the reason).
@@ -12682,7 +13424,14 @@ function mcpServerResponse() {
12682
13424
  // While enrolled with custom servers off, say which servers stay configured
12683
13425
  // but never reach bots, and why. Nothing here is written to config.json.
12684
13426
  const policy = managedPolicy.current();
12685
- 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 });
12686
13435
  return { servers, ...(policy && !policy.mcp.allowCustom ? { managed: { organizationName: policy.organizationName, allowlist: policy.mcp.allowlist } } : {}) };
12687
13436
  }
12688
13437
  /** Adding or editing a server the organisation has not approved is refused. */
@@ -12697,7 +13446,7 @@ function mcpServerBody(body) {
12697
13446
  if (!body || typeof body !== "object")
12698
13447
  return out;
12699
13448
  const record = body;
12700
- for (const key of ["command", "args", "env", "type", "url", "headers", "enabled"]) {
13449
+ for (const key of ["command", "args", "env", "type", "url", "headers", "oauth", "enabled"]) {
12701
13450
  if (record[key] !== undefined)
12702
13451
  out[key] = record[key];
12703
13452
  }
@@ -12706,7 +13455,7 @@ function mcpServerBody(body) {
12706
13455
  /** Configured MCP servers that may reach this bot's engine. While enrolled,
12707
13456
  * the organisation's allow-list filters them; config.json is never changed. */
12708
13457
  function engineMcpServers(bot) {
12709
- return managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers));
13458
+ return withoutPendingSignIn(managedPolicy.filterMcp(customMcpServers(cfg, bot.mcpServers)), mcpOAuth);
12710
13459
  }
12711
13460
  function persistMcpServers(next) {
12712
13461
  saveConfig({ mcpServers: next });
@@ -12720,7 +13469,11 @@ async function describeInstances() {
12720
13469
  const configs = providerConfigs();
12721
13470
  return (await registry.describe()).map((instance) => {
12722
13471
  const entry = configs[instance.instanceId];
12723
- 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
+ };
12724
13477
  if (hostedModels)
12725
13478
  return { ...described, readOnly: true,
12726
13479
  install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
@@ -12732,8 +13485,16 @@ async function describeInstances() {
12732
13485
  ...described, ...policy, readOnly: true, managed: managedDesktop.info(instance.instanceId),
12733
13486
  install: undefined, authentication: undefined, cli: undefined, cliCandidates: [],
12734
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 };
12735
13492
  if (entry?.driver !== "claudeAgent")
12736
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
+ }
12737
13498
  try {
12738
13499
  const claudeAccount = claudeAccountInfo(instance.instanceId, entry, instance.cli ?? instance.cliDefault ?? "claude");
12739
13500
  return { ...described, ...policy, claudeAccount, install: { ...instance.install, signInCommand: claudeAccount.signInCommand } };
@@ -12872,6 +13633,7 @@ async function reloadProviders() {
12872
13633
  stopScreenPoller(botId, threadId);
12873
13634
  releaseLocalVmThread(threadId);
12874
13635
  releaseTurnResources(owner);
13636
+ endForeignTurns(threadId);
12875
13637
  vpsThreadEnded(botId, threadId);
12876
13638
  watchdog.settle(threadId);
12877
13639
  closeOpenApprovals(threadId);
@@ -12891,6 +13653,7 @@ async function reloadProviders() {
12891
13653
  for (const [threadId, speaker] of rooms) {
12892
13654
  releaseLocalVmThread(threadId);
12893
13655
  releaseTurnResources(turnResourceOwners.get(threadId));
13656
+ endForeignTurns(threadId);
12894
13657
  watchdog.settle(threadId);
12895
13658
  closeOpenApprovals(threadId);
12896
13659
  if (groupSpeakers.get(threadId) !== speaker)
@@ -12922,6 +13685,10 @@ let mcpProbesInFlight = 0;
12922
13685
  // install, and running two self-updates against it would race its files.
12923
13686
  const claudeUpdatesInFlight = new Set();
12924
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" };
12925
13692
  /** The built UI, when this process serves it (OMB_STATIC_DIR: set by the
12926
13693
  * desktop app and by the container image). Public by design: it is the same
12927
13694
  * bundle anyone can download, holds no secrets, and a remote browser must be
@@ -12934,7 +13701,8 @@ function serveStatic(res, path) {
12934
13701
  const file = join(STATIC_DIR, safe);
12935
13702
  try {
12936
13703
  const data = readFileSync(file);
12937
- 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 : {}) });
12938
13706
  res.end(data);
12939
13707
  return true;
12940
13708
  }
@@ -12942,7 +13710,7 @@ function serveStatic(res, path) {
12942
13710
  // SPA fallback
12943
13711
  try {
12944
13712
  const data = readFileSync(join(STATIC_DIR, "index.html"));
12945
- res.writeHead(200, { "content-type": "text/html" });
13713
+ res.writeHead(200, { "content-type": "text/html", ...PAGE_HEADERS });
12946
13714
  res.end(data);
12947
13715
  return true;
12948
13716
  }
@@ -13026,6 +13794,23 @@ ROUTES.push(createBotMemoryRoutes({
13026
13794
  bot: (id) => store.bot(id),
13027
13795
  taskByThread: (botId, threadId) => store.taskByThread(botId, threadId),
13028
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
+ } : {}),
13029
13814
  aboutMe: () => cfg.profile?.aboutMe ?? "",
13030
13815
  saveAboutMe: (aboutMe) => {
13031
13816
  saveConfig({ profile: { ...cfg.profile, aboutMe } });
@@ -13034,6 +13819,13 @@ ROUTES.push(createBotMemoryRoutes({
13034
13819
  },
13035
13820
  }));
13036
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);
13037
13829
  const toolResults = new ToolResults();
13038
13830
  const handleRequest = async (req, res) => {
13039
13831
  let url;
@@ -13090,7 +13882,7 @@ const handleRequest = async (req, res) => {
13090
13882
  if (method === "GET" && !path.startsWith("/api/") && !path.startsWith("/.well-known/") && serveStatic(res, path))
13091
13883
  return;
13092
13884
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
13093
- return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
13885
+ return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !HOSTED_WORKSPACE && !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() }));
13094
13886
  }
13095
13887
  const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
13096
13888
  if (method === "GET" && domainCheck) {
@@ -13100,7 +13892,11 @@ const handleRequest = async (req, res) => {
13100
13892
  }
13101
13893
  // Sign in with an emailed code (server/account-signin.ts). Public like
13102
13894
  // /api/auth/pair, JSON-only for the same reason, and counted against the
13103
- // 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
+ }
13104
13900
  if (method === "POST" && (path === "/api/auth/email/start" || path === "/api/auth/email/verify")) {
13105
13901
  if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
13106
13902
  return json(res, 415, { error: "send the sign-in request as JSON (content-type: application/json)" });
@@ -13170,13 +13966,27 @@ const handleRequest = async (req, res) => {
13170
13966
  const wantsCookie = body?.cookie === true;
13171
13967
  const label = typeof body?.label === "string" ? body.label : "";
13172
13968
  const attemptId = typeof body?.attemptId === "string" ? body.attemptId : undefined;
13173
- 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"]) });
13174
13980
  if (!result.ok) {
13175
13981
  console.warn(`pairing refused from ${requestSource(req)}: ${result.error}`);
13176
13982
  return json(res, result.status, { error: result.error });
13177
13983
  }
13178
- const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: emailSignIn.enabled(), sharedComputers: lendingEnabled() });
13984
+ const environment = environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED, emailSignIn: !CLOUD_HOME && emailSignIn.enabled(), sharedComputers: lendingEnabled() });
13179
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);
13180
13990
  const secure = requestOrigin(req)?.startsWith("https://") === true;
13181
13991
  res.setHeader("set-cookie", serializeSessionCookie(SESSION_COOKIE, result.token, { secure, maxAgeSeconds: cookieMaxAgeSeconds(result.session) }));
13182
13992
  return json(res, 200, { session: result.session, environment });
@@ -13310,12 +14120,18 @@ const handleRequest = async (req, res) => {
13310
14120
  environmentId: ENVIRONMENT_ID,
13311
14121
  // the account behind the session, when it came from a sign-in
13312
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 } : {}),
13313
14125
  // a hosted team workspace: the web UI's first run skips the
13314
14126
  // desktop-only beats there. Absent everywhere else.
13315
14127
  ...(HOSTED_WORKSPACE ? { hosted: true } : {}),
13316
14128
  // an OMB Cloud home: the web UI's first run is its engine
13317
14129
  // sign-in (docs/cloud-pro.md). Absent everywhere else.
13318
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) } : {}),
13319
14135
  });
13320
14136
  }
13321
14137
  if (method === "POST" && path === "/api/auth/stream-ticket") {
@@ -13339,7 +14155,16 @@ const handleRequest = async (req, res) => {
13339
14155
  }
13340
14156
  const requested = body?.scopes;
13341
14157
  const scopes = Array.isArray(requested) ? requested.filter((v) => v === "admin" || v === "client") : undefined;
13342
- 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
+ }
13343
14168
  const origin = requestOrigin(req);
13344
14169
  const base = publicUrl() ?? (auth.kind === "session" && origin ? origin : null);
13345
14170
  const code = formatPairingCode(opened.code);
@@ -13702,19 +14527,31 @@ const handleRequest = async (req, res) => {
13702
14527
  // Notes written from a room fewer people can see than this bot would
13703
14528
  // carry that room's words to everyone who can see the bot.
13704
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
+ }
13705
14539
  const room = store.groupByThread(internalCapability.threadId);
13706
14540
  if (room && !roomFeedsBot(room, internalSender)) {
13707
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." });
13708
14542
  }
13709
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();
13710
14547
  if (method === "POST" && path === "/api/internal/memory") {
13711
14548
  const body = await readInternalBody();
13712
- const result = updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() });
14549
+ const result = ownersWrite(() => updateMemory(internalSender.id, { action: body.action, text: body.text, oldText: body.oldText, ...(body.until !== undefined ? { until: body.until } : {}) }, { source: memorySource() }));
13713
14550
  return json(res, result.ok ? 200 : result.code === "conflict" ? 409 : result.code === "over-budget" ? 413 : 400, result);
13714
14551
  }
13715
14552
  if (method === "POST" && path === "/api/internal/memory/log") {
13716
14553
  const body = await readInternalBody();
13717
- const result = appendMemoryLog(internalSender.id, body.text, { source: memorySource() });
14554
+ const result = ownersWrite(() => appendMemoryLog(internalSender.id, body.text, { source: memorySource() }));
13718
14555
  return json(res, result.ok ? 200 : 400, result);
13719
14556
  }
13720
14557
  if (method === "POST" && path === "/api/internal/browser/mcp") {
@@ -13762,8 +14599,8 @@ const handleRequest = async (req, res) => {
13762
14599
  if (method === "GET" && path === "/api/internal/shared-computers" && lendingEnabled()) {
13763
14600
  const principal = sharedComputerPrincipal(internalCapability);
13764
14601
  // Say why an owner's own conversation sees no Mac, so the bot can tell them.
13765
- const notice = CLOUD_HOME && !principal && sharedComputers.status(CLOUD_HOME_LENDER).length > 0 &&
13766
- cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) === "someone-else" ? { unavailable: LENDING_SOMEONE_ELSE } : {};
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 } : {};
13767
14604
  return json(res, 200, { computers: sharedComputers.list(principal), ...notice });
13768
14605
  }
13769
14606
  if (method === "POST" && path === "/api/internal/shared-computers" && lendingEnabled()) {
@@ -13777,8 +14614,9 @@ const handleRequest = async (req, res) => {
13777
14614
  return json(res, 403, { error: "This bot has no computer. Change its Computer setting to use a shared computer's apps and screen." });
13778
14615
  }
13779
14616
  const principal = sharedComputerPrincipal(internalCapability);
13780
- if (CLOUD_HOME && !principal && cloudHomeLendingRefusal(cloudLendingTurn(internalCapability)) === "someone-else") {
13781
- return json(res, 403, { error: LENDING_SOMEONE_ELSE });
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 });
13782
14620
  }
13783
14621
  return json(res, 200, { result: await sharedComputers.request(parsed.data, principal, () => lendingEnabled() && internalCapabilityIsActive(internalCapability)) });
13784
14622
  }
@@ -13837,7 +14675,13 @@ const handleRequest = async (req, res) => {
13837
14675
  return "closed";
13838
14676
  return "idle";
13839
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;
13840
14682
  for (const task of store.tasks(from.id)) {
14683
+ if (!shown(task.threadId))
14684
+ continue;
13841
14685
  rows.push({
13842
14686
  threadId: task.threadId, botId: from.id, botName: from.name, title: task.title,
13843
14687
  state: stateOf(from, task), unread: task.unread === true, openedAt: task.openedBy?.at ?? task.createdAt,
@@ -13846,7 +14690,7 @@ const handleRequest = async (req, res) => {
13846
14690
  }
13847
14691
  for (const peer of reachablePeers(store.bots, from)) {
13848
14692
  for (const task of store.tasks(peer.id)) {
13849
- if (task.openedBy?.botId !== from.id)
14693
+ if (task.openedBy?.botId !== from.id || !shown(task.threadId))
13850
14694
  continue;
13851
14695
  rows.push({
13852
14696
  threadId: task.threadId, botId: peer.id, botName: peer.name, title: task.title,
@@ -14079,6 +14923,12 @@ const handleRequest = async (req, res) => {
14079
14923
  : body.action === "update"
14080
14924
  ? { action: body.action, routineId: body.routineId, changes: body.changes, forBot }
14081
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);
14082
14932
  const proposed = await routineRequests.submit({
14083
14933
  botId: from.id,
14084
14934
  threadId: fromThreadId,
@@ -14087,6 +14937,17 @@ const handleRequest = async (req, res) => {
14087
14937
  canCommit: () => internalCapabilityIsActive(internalCapability),
14088
14938
  });
14089
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
+ }
14090
14951
  appendDecision(DATA_DIR, {
14091
14952
  threadId: fromThreadId,
14092
14953
  requestId: proposed.requestId,
@@ -14305,7 +15166,7 @@ const handleRequest = async (req, res) => {
14305
15166
  if (scope !== "all" && scope !== "conversations" && scope !== "memory") {
14306
15167
  return json(res, 400, { error: "scope must be all, conversations, or memory" });
14307
15168
  }
14308
- const memoryHits = scope === "conversations" || !q ? [] : searchMemoryFiles(from.id, q, limit);
15169
+ const memoryHits = scope === "conversations" || !q || from.memoryEnabled === false ? [] : searchMemoryFiles(from.id, q, limit);
14309
15170
  if (scope === "memory")
14310
15171
  return json(res, 200, { hits: [], memoryHits });
14311
15172
  // Own threads: the bot's main chat and tasks, and the rooms it is a
@@ -14321,7 +15182,10 @@ const handleRequest = async (req, res) => {
14321
15182
  for (const task of group.tasks ?? [])
14322
15183
  roomByThread.set(task.threadId, group);
14323
15184
  }
14324
- 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);
14325
15189
  // A room is the only place a recall can be a disclosure, and only a
14326
15190
  // private chat is one: in a 1:1 the user already owns every thread
14327
15191
  // the bot can reach, and a room's lines were said in the open.
@@ -14360,7 +15224,8 @@ const handleRequest = async (req, res) => {
14360
15224
  const messageId = String(url.searchParams.get("messageId") ?? "").trim();
14361
15225
  if (!threadId || !messageId)
14362
15226
  return json(res, 400, { error: "threadId and messageId are required" });
14363
- 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));
14364
15229
  const message = own ? readMessageText(threadId, messageId) : null;
14365
15230
  if (!message)
14366
15231
  return json(res, 404, { error: "no such message in your conversations" });
@@ -14530,7 +15395,12 @@ const handleRequest = async (req, res) => {
14530
15395
  // can check next turn. If the ledger refuses (cap/depth), fall back
14531
15396
  // to the plain busy bounce rather than dropping the refusal reason.
14532
15397
  const queueBusyFallback = (approvalAlreadyGranted = false) => {
14533
- 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);
14534
15404
  if (queued.result !== "ok" || !queued.id)
14535
15405
  return json(res, 200, { busy: true, receipt: peerDeliveryReceipt({
14536
15406
  botId: toBotId, botName: target.name, outcome: "failed",
@@ -14558,6 +15428,10 @@ const handleRequest = async (req, res) => {
14558
15428
  const tryAsideDelivery = async () => {
14559
15429
  if (peerReviewRequired(from, fromThreadId))
14560
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;
14561
15435
  // Busy elsewhere (room turn, sibling thread): the thread this ask
14562
15436
  // targets is not the running one, so there is nothing to fold
14563
15437
  // into here — the queue lane keeps it.
@@ -14664,7 +15538,10 @@ const handleRequest = async (req, res) => {
14664
15538
  delivery: "ask_bot",
14665
15539
  unattended: isUnattended(currentFrom.id, fromThreadId),
14666
15540
  });
14667
- 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;
14668
15545
  const outcome = await askBotAndWait(toBotId, prefixed, depth, fromBotId, fromThreadId, targetThreadId);
14669
15546
  requireActiveInternalCapability();
14670
15547
  const replySender = store.bot(fromBotId);
@@ -14876,7 +15753,12 @@ const handleRequest = async (req, res) => {
14876
15753
  if (!connectorThread(from.id, fromThreadId)) {
14877
15754
  return json(res, 403, { error: "source thread does not belong to sender" });
14878
15755
  }
14879
- 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);
14880
15762
  if (queued.result !== "ok" || !queued.id) {
14881
15763
  // the agent reads this string — a bare enum ("too_deep") tells it
14882
15764
  // nothing about what to do instead
@@ -14944,7 +15826,7 @@ const handleRequest = async (req, res) => {
14944
15826
  })).filter(g => g.members.length);
14945
15827
  return json(res, 200, { currentRoom: source ? { id: source.id, name: source.name, workingFolder: source.cwd || null } : null,
14946
15828
  bots: reachablePeers(store.bots, internalSender).map(bot => ({ id: bot.id, name: bot.name, title: bot.title, section: bot.section, busy: bot.busy })),
14947
- rooms, note: "Without group_id: use this room when in a room, otherwise 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." });
14948
15830
  }
14949
15831
  if (method === "POST" && path === "/api/internal/coordinate-bots") {
14950
15832
  const parsed = z.object({
@@ -15021,39 +15903,21 @@ const handleRequest = async (req, res) => {
15021
15903
  try {
15022
15904
  requireActiveInternalCapability();
15023
15905
  if (!destination) {
15024
- // One durable conversation per pair of bots, resolved from
15025
- // the recipient's own threads — never from this turn, the
15026
- // request key, or the thread the person has selected there.
15027
- const resolved = store.resolvePairConversation(internalSender, target.botId, {
15028
- label: parsed.data.label,
15029
- // "Still working" exactly as close_thread reads it: a
15030
- // running turn, a queued one, or coordinated work already
15031
- // addressed at that thread.
15032
- working: threadId => threadBusy(target.botId, threadId)
15033
- || queuedThreadPosition(target.botId, threadId) !== null
15034
- || roomHandoffs.activeDirect(threadId),
15035
- });
15036
- if (!resolved)
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)
15037
15908
  throw new Error("The recipient no longer exists");
15038
- target.threadId = resolved.task.threadId;
15039
- if (resolved.created)
15040
- createdThread = resolved.task.threadId;
15041
- // A work thread carries one assignment: it is for the person
15042
- // this coordination serves. The durable pair conversation is
15043
- // shared by every assignment between two bots, so it names nobody.
15044
- if (resolved.created && resolved.task.openedBy?.kind === "work")
15045
- threadStarters.set(resolved.task.threadId, threadPersonKey(address.threadId));
15909
+ target.threadId = createdThread = task.threadId;
15046
15910
  if (delegatedFullAccess(internalSender, internalCapability.threadId, store.bot(target.botId))) {
15047
15911
  grantDelegatedFullAccess(internalSender, store.bot(target.botId), target.threadId);
15048
15912
  }
15049
15913
  }
15050
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);
15051
- // A re-dispatched request_key is answered by the request it
15052
- // already made, so a thread resolved for the retry (the pair
15053
- // conversation was busy with that very request) goes back
15054
- // before anyone sees a row that leads nowhere.
15055
- if (duplicate && createdThread && createdThread !== node.threadId)
15915
+ // A retry keeps its original task; discard the speculative row.
15916
+ if (duplicate && createdThread && createdThread !== node.threadId) {
15056
15917
  store.deleteTask(target.botId, createdThread);
15918
+ }
15919
+ if (!duplicate && createdThread)
15920
+ threadStarters.set(createdThread, openerFrom(address.threadId));
15057
15921
  createdThread = undefined; // The durable coordinator now owns this task.
15058
15922
  accepted.push({ requestId: node.id, botId: node.botId, duplicate, status: node.status });
15059
15923
  // A duplicate request_key lands on the node enqueue already
@@ -15093,8 +15957,9 @@ const handleRequest = async (req, res) => {
15093
15957
  }
15094
15958
  }
15095
15959
  catch (error) {
15096
- if (createdThread)
15960
+ if (createdThread) {
15097
15961
  store.deleteTask(target.botId, createdThread);
15962
+ }
15098
15963
  const said = error instanceof Error ? error.message : String(error);
15099
15964
  errors.push({ botId: target.botId, error: said });
15100
15965
  receipts.push(peerDeliveryReceipt({
@@ -15337,10 +16202,11 @@ const handleRequest = async (req, res) => {
15337
16202
  }
15338
16203
  const sourceTitle = owner.group ? owner.group.name : (store.taskByThread(from.id, fromThreadId)?.title ?? "");
15339
16204
  if (target.id === from.id) {
15340
- 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);
15341
16207
  if (!task)
15342
16208
  return json(res, 500, { error: "couldn't create that thread" });
15343
- threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
16209
+ threadStarters.set(task.threadId, openerFrom(fromThreadId));
15344
16210
  internalCapability.openedThreads += 1;
15345
16211
  const chip = {
15346
16212
  role: "bot",
@@ -15383,7 +16249,7 @@ const handleRequest = async (req, res) => {
15383
16249
  if (!task)
15384
16250
  return json(res, 500, { error: "couldn't create that thread" });
15385
16251
  // The work is still for the person whose request the opener is on.
15386
- threadStarters.set(task.threadId, threadPersonKey(fromThreadId));
16252
+ threadStarters.set(task.threadId, openerFrom(fromThreadId));
15387
16253
  if (delegatedFullAccess(from, fromThreadId, target))
15388
16254
  grantDelegatedFullAccess(from, target, task.threadId);
15389
16255
  const queued = queueDelegation(commsBus, from, { toBotId: target.id, message, depth, targetThreadId: task.threadId }, MAX_COMMS_DEPTH, fromThreadId);
@@ -15932,16 +16798,25 @@ const handleRequest = async (req, res) => {
15932
16798
  .filter((run) => routineVisible(run, visible)),
15933
16799
  });
15934
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;
15935
16805
  if (path === "/api/routines" && method === "POST") {
15936
16806
  const body = await readBody(req);
15937
16807
  const hidden = hiddenRoutineTarget(body, visible);
15938
16808
  if (hidden)
15939
16809
  return json(res, 404, { error: hidden });
15940
- const 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));
15941
16815
  // On a Cloud home, a routine the owner writes from their own device may
15942
16816
  // use their lent Mac when it runs (server/cloud-lending.ts).
15943
16817
  if (cloudRoutineAuthors && cloudOwnerSession(auth))
15944
16818
  cloudRoutineAuthors.record(routine.id, routine);
16819
+ cloudRoutineAuthors?.wrote(routine.id, writer);
15945
16820
  return json(res, 201, { routine });
15946
16821
  }
15947
16822
  // The desktop shell polls this to decide whether to hold the computer
@@ -15962,9 +16837,27 @@ const handleRequest = async (req, res) => {
15962
16837
  const hidden = hiddenRoutineTarget(body, visible);
15963
16838
  if (hidden)
15964
16839
  return json(res, 404, { error: hidden });
16840
+ const notYours = routineResultsRefusal(body);
16841
+ if (notYours)
16842
+ return json(res, 403, { error: notYours });
15965
16843
  const before = cloudRoutineAuthors ? routines.listRoutines().find((candidate) => candidate.id === routineMatch[1]) : undefined;
15966
16844
  const wasOwners = Boolean(before && cloudRoutineAuthors?.authored(before.id, before));
15967
- const routine = routines.update(routineMatch[1], body);
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));
15968
16861
  // The owner's own edit keeps (or, when it rewrites the instructions,
15969
16862
  // makes) the routine theirs. Anyone else's edit, of any field (its
15970
16863
  // schedule, where its results go, whether it is on), makes it no longer
@@ -15972,15 +16865,16 @@ const handleRequest = async (req, res) => {
15972
16865
  if (routine && cloudRoutineAuthors) {
15973
16866
  if (!cloudOwnerSession(auth))
15974
16867
  cloudRoutineAuthors.forget(routine.id);
15975
- else if (wasOwners || (body && typeof body === "object" && "prompt" in body))
16868
+ else if (ownerAuthors)
15976
16869
  cloudRoutineAuthors.record(routine.id, routine);
16870
+ cloudRoutineAuthors.wrote(routine.id, writer);
15977
16871
  }
15978
16872
  return routine ? json(res, 200, { routine }) : json(res, 404, { error: "no such routine" });
15979
16873
  }
15980
16874
  if (routineMatch && method === "DELETE") {
15981
16875
  const removed = routines.remove(routineMatch[1]);
15982
16876
  if (removed)
15983
- cloudRoutineAuthors?.forget(routineMatch[1]);
16877
+ cloudRoutineAuthors?.remove(routineMatch[1]);
15984
16878
  return removed
15985
16879
  ? json(res, 200, { ok: true })
15986
16880
  : json(res, 404, { error: "no such routine" });
@@ -16742,6 +17636,9 @@ const handleRequest = async (req, res) => {
16742
17636
  if (hiddenMember)
16743
17637
  return json(res, 400, { error: `unknown channel member: ${String(hiddenMember)}` });
16744
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));
16745
17642
  return json(res, 201, { group: { ...publicGroupState(group), messages: [] } });
16746
17643
  }
16747
17644
  if (method === "POST" && path === "/api/teams/export") {
@@ -17209,7 +18106,7 @@ const handleRequest = async (req, res) => {
17209
18106
  return json(res, 500, { error: "couldn't create that task" });
17210
18107
  // Who opened it decides who may answer its cards on a shared workspace.
17211
18108
  if (auth.kind === "session")
17212
- threadStarters.set(task.threadId, personKey(auth.session));
18109
+ threadStarters.set(task.threadId, actorKey(auth));
17213
18110
  const fresh = groupWithThread(store.group(group.id));
17214
18111
  broadcast({ kind: "group", group: fresh });
17215
18112
  return json(res, 201, { group: fresh, task });
@@ -17286,6 +18183,9 @@ const handleRequest = async (req, res) => {
17286
18183
  // without the old handler turning a missing title into "Untitled".
17287
18184
  // That echo is not a rename. A real rename stays blocked while working.
17288
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 });
17289
18189
  if (channelTaskBlocked(group) && !(pinning && !titleChange)) {
17290
18190
  return json(res, 409, { error: "this channel is working or waiting on you — finish that turn first" });
17291
18191
  }
@@ -17318,6 +18218,9 @@ const handleRequest = async (req, res) => {
17318
18218
  }
17319
18219
  if (!store.groupTaskByThread(group.id, m[2]))
17320
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 });
17321
18224
  const stagedSkillCleanups = stagedSkillCleanupsForThread(m[2]);
17322
18225
  lastReply.delete(m[2]);
17323
18226
  cancelTeamSetupResumesForThread(m[2]);
@@ -17339,6 +18242,14 @@ const handleRequest = async (req, res) => {
17339
18242
  if (field)
17340
18243
  return json(res, 403, { error: `forbidden: this session may rename or mark a room, not change "${field}" (needs the admin scope)` });
17341
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
+ }
17342
18253
  const group = updateChannel(m[1], body);
17343
18254
  return json(res, 200, { group: publicGroupState(group) });
17344
18255
  }
@@ -17963,9 +18874,14 @@ const handleRequest = async (req, res) => {
17963
18874
  throw Object.assign(new Error(enabled.error), { status: 422 });
17964
18875
  }
17965
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;
17966
18880
  for (const routine of template.routines) {
17967
18881
  const created = routines.create({ ...routine, botId: bot.id, enabled: false });
17968
18882
  createdRoutines.push({ id: created.id, enabled: routine.enabled !== false });
18883
+ if (ownersRoutines)
18884
+ cloudRoutineAuthors?.wrote(created.id, ownersRoutines);
17969
18885
  }
17970
18886
  }
17971
18887
  catch (error) {
@@ -18051,6 +18967,17 @@ const handleRequest = async (req, res) => {
18051
18967
  return json(res, 400, { error: "avatarUrl must reference an existing stored image" });
18052
18968
  }
18053
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
+ }
18054
18981
  const beforeProfile = existingBot ? profileSnapshot(existingBot) : undefined;
18055
18982
  const bot = store.patchBotProfile(m[1], parsed.patch);
18056
18983
  if (!bot)
@@ -18304,6 +19231,15 @@ const handleRequest = async (req, res) => {
18304
19231
  return json(res, 400, { error: "voiceNotes must be true or false" });
18305
19232
  patch.voiceNotes = body.voiceNotes;
18306
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
+ }
18307
19243
  // which of those apps' tools this bot may call (connector grants 1/5:
18308
19244
  // data model only — enforcement lands with slice 2). null returns the
18309
19245
  // bot to boolean-only legacy behavior; {} means no tools.
@@ -18640,6 +19576,13 @@ const handleRequest = async (req, res) => {
18640
19576
  sectionKey(existingBot?.section) !== sectionKey(section);
18641
19577
  let bot;
18642
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
+ }
18643
19586
  // Custom servers are process configuration: a running turn cannot
18644
19587
  // unmount them. Recheck after any awaited runtime revocation above.
18645
19588
  if (body.mcpServers !== undefined) {
@@ -18695,7 +19638,7 @@ const handleRequest = async (req, res) => {
18695
19638
  }
18696
19639
  if (!bot)
18697
19640
  return json(res, 404, { error: "no such bot" });
18698
- if (body.memoryUpkeep === false)
19641
+ if (body.memoryUpkeep === false || body.memoryEnabled === false)
18699
19642
  memoryUpkeep.dropBot(bot.id);
18700
19643
  // A defined Works on is the newest explicit choice: this bot's
18701
19644
  // auto-recorded pins that now point elsewhere give way immediately, so
@@ -19165,6 +20108,9 @@ const handleRequest = async (req, res) => {
19165
20108
  // receipt after a task switch, while a genuinely new send still has to
19166
20109
  // target the task that is active now.
19167
20110
  const threadId = body.threadId ?? bot.threadId;
20111
+ const notYours = cloudGuestSendRefusal(auth, threadId);
20112
+ if (notYours)
20113
+ return json(res, 403, { error: notYours });
19168
20114
  // Who this message is from, for the ledger. It is captured here and
19169
20115
  // travels with the message: into the turn it starts, or into the queue
19170
20116
  // until it drains. A message steered into someone else's running turn
@@ -19425,6 +20371,9 @@ const handleRequest = async (req, res) => {
19425
20371
  const body = await readBody(req);
19426
20372
  requirePinnedClientThread(m[1], body?.threadId);
19427
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 });
19428
20377
  await startTurn(bot.id, "Summarize conversation context", {
19429
20378
  threadId: bot.threadId, compactOnly: true, cardContinuation: true, trigger: usageTriggerFor(auth),
19430
20379
  });
@@ -19440,6 +20389,9 @@ const handleRequest = async (req, res) => {
19440
20389
  const body = await readBody(req);
19441
20390
  requirePinnedClientThread(m[1], body?.threadId);
19442
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 });
19443
20395
  const text = String(body.text ?? "").trim();
19444
20396
  if (!text)
19445
20397
  return json(res, 400, { error: "text required" });
@@ -19492,6 +20444,9 @@ const handleRequest = async (req, res) => {
19492
20444
  const body = await readBody(req);
19493
20445
  requirePinnedClientThread(m[1], body?.threadId);
19494
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 });
19495
20450
  if (threadBusy(bot.id, bot.threadId))
19496
20451
  return json(res, 409, { error: "the thread is working — stop it before switching versions" });
19497
20452
  if (phoneSecretSubmissions.hasThread(bot.threadId)) {
@@ -19532,7 +20487,7 @@ const handleRequest = async (req, res) => {
19532
20487
  threadId: bot.threadId,
19533
20488
  requestId: String(body.requestId),
19534
20489
  behavior,
19535
- }))
20490
+ }, auth.kind === "session" && cloudOwnerSession(auth)))
19536
20491
  return;
19537
20492
  if (resolveAndSendProfile(res, {
19538
20493
  botId: bot.id,
@@ -19630,7 +20585,7 @@ const handleRequest = async (req, res) => {
19630
20585
  threadId,
19631
20586
  requestId,
19632
20587
  behavior,
19633
- }))
20588
+ }, auth.kind === "session" && cloudOwnerSession(auth)))
19634
20589
  return;
19635
20590
  }
19636
20591
  const setupCard = store.messagesFor(threadId).find((message) => message.card?.requestId === requestId && message.card.teamSetupRequest);
@@ -19869,7 +20824,7 @@ const handleRequest = async (req, res) => {
19869
20824
  return json(res, 500, { error: "couldn't create that task" });
19870
20825
  // Who opened it decides who may answer its cards on a shared workspace.
19871
20826
  if (auth.kind === "session")
19872
- threadStarters.set(task.threadId, personKey(auth.session));
20827
+ threadStarters.set(task.threadId, actorKey(auth));
19873
20828
  const fresh = botWithThread(store.bot(bot.id));
19874
20829
  broadcast({ kind: "bot", bot: fresh });
19875
20830
  return json(res, 201, { bot: fresh, task: wireTask(task) });
@@ -19915,6 +20870,16 @@ const handleRequest = async (req, res) => {
19915
20870
  const allowed = new Set(["title", "projectId", "modelSelection", "updateBotDefault", "resetApprovalToAsk", "approvalMode", "autoApprove", "requireAvailableModel", "pinnedMessageId", "acknowledgeLocalAuto", "archivedAt", "pinned", "snoozedUntil", "surface"]);
19916
20871
  if (Object.keys(body).some((key) => !allowed.has(key)))
19917
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
+ }
19918
20883
  for (const key of ["requireAvailableModel", "acknowledgeLocalAuto", "updateBotDefault", "resetApprovalToAsk"]) {
19919
20884
  if (body[key] !== undefined && typeof body[key] !== "boolean")
19920
20885
  return json(res, 400, { error: `${key} must be a boolean` });
@@ -20040,6 +21005,9 @@ const handleRequest = async (req, res) => {
20040
21005
  if (!bot || !store.taskByThread(bot.id, m[2])) {
20041
21006
  return json(res, 404, { error: "no such task" });
20042
21007
  }
21008
+ const notYours = cloudThreadRefusal(auth, m[2]);
21009
+ if (notYours)
21010
+ return json(res, 403, { error: notYours });
20043
21011
  if (phoneSecretSubmissions.hasThread(m[2])) {
20044
21012
  return json(res, 409, { error: "this task is securely saving a credential — try again when it finishes" });
20045
21013
  }
@@ -20060,6 +21028,55 @@ const handleRequest = async (req, res) => {
20060
21028
  broadcast({ kind: "bot", bot: fresh });
20061
21029
  return json(res, 200, { bot: fresh });
20062
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
+ }
20063
21080
  // Named team Boats use real independent ownership, never a hidden bot or
20064
21081
  // an arbitrary provider id. These new routes remain admin-only by default.
20065
21082
  if (path === "/api/team-computers" && method === "GET") {
@@ -20253,7 +21270,7 @@ const handleRequest = async (req, res) => {
20253
21270
  // what the user's machine can host: which runtime is installed, whether
20254
21271
  // its daemon is up, and whether the desktop image and container exist
20255
21272
  if (method === "GET" && path === "/api/local-computer") {
20256
- return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET));
21273
+ return json(res, 200, await localVmPayload(SHARED_LOCAL_VM_TARGET, auth));
20257
21274
  }
20258
21275
  if (method === "GET" && path === "/api/local-computer/instances") {
20259
21276
  res.setHeader("cache-control", "private, no-store");
@@ -20299,9 +21316,9 @@ const handleRequest = async (req, res) => {
20299
21316
  if (action === "stop" || action === "remove")
20300
21317
  localVmIdleFor(SHARED_LOCAL_VM_TARGET).cancel();
20301
21318
  return json(res, 200, {
20302
- ...status,
21319
+ ...localVmViewerStatus(status, auth),
20303
21320
  commands: setupCommands(status.runtime, process.platform, SHARED_LOCAL_VM_TARGET),
20304
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
21321
+ idle_timeout_ms: localVmIdleMs(),
20305
21322
  mode: localVmMode(cfg),
20306
21323
  max_instances: localVmMaxInstances(cfg),
20307
21324
  });
@@ -20325,7 +21342,7 @@ const handleRequest = async (req, res) => {
20325
21342
  const bot = computerPreviewBot(m[1], url);
20326
21343
  if (!bot)
20327
21344
  return json(res, 404, { error: "no such bot" });
20328
- return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId)));
21345
+ return json(res, 200, await localVmPayload(localVmTargetForStatus(bot.id, bot.threadId), auth));
20329
21346
  }
20330
21347
  m = path.match(/^\/api\/bots\/([\w-]+)\/local-computer\/(run|stop|remove)$/);
20331
21348
  if (m && method === "POST") {
@@ -20382,9 +21399,9 @@ const handleRequest = async (req, res) => {
20382
21399
  if (action === "stop" || action === "remove")
20383
21400
  localVmIdleFor(target).cancel();
20384
21401
  return json(res, 200, {
20385
- ...status,
21402
+ ...localVmViewerStatus(status, auth),
20386
21403
  commands: setupCommands(status.runtime, process.platform, target),
20387
- idle_timeout_ms: LOCAL_VM_IDLE_MS,
21404
+ idle_timeout_ms: localVmIdleMs(),
20388
21405
  mode: localVmMode(cfg),
20389
21406
  max_instances: localVmMaxInstances(cfg),
20390
21407
  });
@@ -20549,7 +21566,7 @@ const handleRequest = async (req, res) => {
20549
21566
  return json(res, 400, { error: `provider must be one of ${PROVIDER_KEY_KINDS.join(", ")}` });
20550
21567
  }
20551
21568
  const kind = provider;
20552
- 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];
20553
21570
  if (body?.key !== undefined && typeof body.key !== "string") {
20554
21571
  return json(res, 400, { error: "key must be a string" });
20555
21572
  }
@@ -20666,6 +21683,30 @@ const handleRequest = async (req, res) => {
20666
21683
  providerConfigBusy = false;
20667
21684
  }
20668
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
+ }
20669
21710
  if (method === "POST" && path === "/api/instances/claude-accounts") {
20670
21711
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
20671
21712
  return json(res, 415, { error: "content-type must be application/json" });
@@ -20716,6 +21757,11 @@ const handleRequest = async (req, res) => {
20716
21757
  const instance = registry.get(instanceId);
20717
21758
  if (!instance)
20718
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
+ }
20719
21765
  const started = await providerAuthSessions.start(instance, owner);
20720
21766
  // Revocation can arrive while the CLI is obtaining a device code.
20721
21767
  if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
@@ -20939,13 +21985,78 @@ const handleRequest = async (req, res) => {
20939
21985
  res.once("close", disconnect);
20940
21986
  mcpProbesInFlight += 1;
20941
21987
  try {
20942
- 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);
20943
21999
  }
20944
22000
  finally {
20945
22001
  res.off("close", disconnect);
20946
22002
  mcpProbesInFlight -= 1;
20947
22003
  }
20948
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
+ }
20949
22060
  if (method === "POST" && path === "/api/mcp/servers") {
20950
22061
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
20951
22062
  return json(res, 415, { error: "content-type must be application/json" });
@@ -21031,6 +22142,7 @@ const handleRequest = async (req, res) => {
21031
22142
  const next = { ...current };
21032
22143
  delete next[name];
21033
22144
  persistMcpServers(next);
22145
+ mcpOAuth.forget(name);
21034
22146
  return json(res, 200, mcpServerResponse());
21035
22147
  }
21036
22148
  const existing = parseStoredMcpServer(name, current[name]);
@@ -21052,6 +22164,17 @@ const handleRequest = async (req, res) => {
21052
22164
  if (refusal)
21053
22165
  return json(res, 403, { error: refusal, code: "managed_policy" });
21054
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);
21055
22178
  return json(res, 200, mcpServerResponse());
21056
22179
  }
21057
22180
  finally {
@@ -21064,9 +22187,13 @@ const handleRequest = async (req, res) => {
21064
22187
  }
21065
22188
  if ((method === "PUT" || method === "PATCH") && path === "/api/config") {
21066
22189
  const body = await readBody(req);
21067
- 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)))
21068
22191
  return json(res, 403, { error: HOSTED_PROVIDER_SETTINGS_ERROR });
21069
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
+ }
21070
22197
  if (patch.newBotDefaults) {
21071
22198
  if (patch.newBotDefaults.profile.modelSelection) {
21072
22199
  const checked = checkedModelSelection(patch.newBotDefaults.profile.modelSelection);
@@ -21506,9 +22633,17 @@ const handleRequest = async (req, res) => {
21506
22633
  }
21507
22634
  throw error;
21508
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
+ }
21509
22642
  let browserReferenceCleanupError = null;
21510
- if (patch.signIn !== undefined)
22643
+ if (patch.signIn !== undefined) {
21511
22644
  sessions.revalidateEmailSessions();
22645
+ resyncOpenCodeProviderKeys();
22646
+ }
21512
22647
  if (!lendingEnabled()) {
21513
22648
  sharedComputers.close();
21514
22649
  sharedComputerControl.close();
@@ -21969,6 +23104,10 @@ const handleRequest = async (req, res) => {
21969
23104
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
21970
23105
  return json(res, 415, { error: "content-type must be application/json" });
21971
23106
  }
23107
+ if (m[2] === "join" && bot.cloudBackend === "vps") {
23108
+ for (let pending; (pending = vpsJoinRequests.get(botId));)
23109
+ await pending;
23110
+ }
21972
23111
  const remoteProvider = bot.cloudBackend === "vps" ? "vps" : "box";
21973
23112
  if (computerProviderConfigTransitions.has(remoteProvider)) {
21974
23113
  return json(res, 409, { error: providerTransitionMessage(remoteProvider) });
@@ -22016,6 +23155,10 @@ const handleRequest = async (req, res) => {
22016
23155
  // Opening the existing SSH viewer can coexist with a capture. Start,
22017
23156
  // stop, remove and Settings deletion still exclude pending previews.
22018
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; }));
22019
23162
  try {
22020
23163
  if (m[2] === "exec") {
22021
23164
  return json(res, 409, { error: "the VPS console is available to the bot through its scoped computer tools" });
@@ -22027,13 +23170,20 @@ const handleRequest = async (req, res) => {
22027
23170
  return json(res, 409, { error: "the VPS computer is being used by this bot — interrupt the turn first" });
22028
23171
  }
22029
23172
  if (m[2] === "join") {
22030
- 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 });
22031
23177
  }
22032
23178
  const action = m[2] === "provision" ? "provision" : m[2] === "remove" ? "remove" : "stop";
22033
23179
  return json(res, 200, await vps.vpsComputerAction(action, cfg, botId));
22034
23180
  }
22035
23181
  finally {
22036
23182
  releaseComputerLifecycle();
23183
+ if (joinSettled) {
23184
+ vpsJoinRequests.delete(botId);
23185
+ joinSettled();
23186
+ }
22037
23187
  }
22038
23188
  }
22039
23189
  const activeBoatTurn = botHasActiveTurn(botId);
@@ -22098,6 +23248,7 @@ const handleRequest = async (req, res) => {
22098
23248
  }
22099
23249
  };
22100
23250
  const server = createServer(handleRequest);
23251
+ desktopViewer.attach(server, handleRequest);
22101
23252
  calendarCalls.start();
22102
23253
  // Resolve the edition before accepting requests so /api/edition is never a guess.
22103
23254
  console.log(describeEdition(await loadEnterpriseLayer()));
@@ -22278,6 +23429,7 @@ if (TUNNEL_SOCKET) {
22278
23429
  if (process.platform !== "win32")
22279
23430
  rmSync(TUNNEL_SOCKET, { force: true });
22280
23431
  tunnelListener = createServer(handleRequest);
23432
+ desktopViewer.attach(tunnelListener, handleRequest);
22281
23433
  tunnelListener.listen(TUNNEL_SOCKET, () => {
22282
23434
  console.log(`openmausbot tunnel listener on ${TUNNEL_SOCKET}`);
22283
23435
  });
@@ -22296,6 +23448,7 @@ const gracefulShutdown = createGracefulShutdown({
22296
23448
  sharedComputers.close();
22297
23449
  sharedComputerControl.close();
22298
23450
  browserLive.closeAll();
23451
+ desktopViewer.closeAll();
22299
23452
  for (const idle of localVmIdles.values())
22300
23453
  idle.cancel();
22301
23454
  vps.closeAllVpsDesktopTunnels();
@@ -22332,6 +23485,7 @@ const gracefulShutdown = createGracefulShutdown({
22332
23485
  code = 1;
22333
23486
  }
22334
23487
  closeMessageDb();
23488
+ mcpOAuth.dispose();
22335
23489
  releaseDataDirLeaseAtExit();
22336
23490
  // A Cloud home's launcher starts the server again on this code only.
22337
23491
  process.exit(cloudHomeRestartRequested && code === 0 ? CLOUD_HOME_RESTART_EXIT_CODE : code);