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
@@ -0,0 +1,479 @@
1
+ // A bot's memory on an OMB Cloud home, as far as a lent Mac is concerned
2
+ // (docs/cloud-pro.md, "Let my Cloud use this Mac").
3
+ //
4
+ // MEMORY.md is loaded into every turn of a bot, its topic files and daily
5
+ // log feed recall, and the instruction files in its working folders
6
+ // (CLAUDE.md, AGENTS.md, .mcp.json, .claude/settings.json, skills) are read
7
+ // by its engine on every turn, the owner's lending turns among them. So a line put
8
+ // there by a conversation the owner did not write (a guest's chat, a room, a
9
+ // webhook run) would steer a turn that can reach the Mac. The harness's own
10
+ // memory writers already refuse such conversations (server/index.ts); this
11
+ // catches everything else, a bot writing the files with its own tools
12
+ // included:
13
+ //
14
+ // - while a turn that is not provably the owner's runs for a bot, that bot's
15
+ // memory is "pending" (whatever the owner changed before it is adopted
16
+ // first);
17
+ // - when the files change while pending, the memory is "changed by someone
18
+ // else" and the bot's turns cannot use the Mac until the owner reviews it
19
+ // in the Memory panel: one click, bound to the exact files they were
20
+ // shown;
21
+ // - any other change (the owner's own turns, the owner's Memory edits,
22
+ // upkeep and tidy) is adopted as the owner's;
23
+ // - a write the harness makes for the owner (trustedWrite) is adopted even
24
+ // while such a turn runs, as long as nothing else changed the memory first.
25
+ // A foreign write racing it within the same moment is the one case this
26
+ // cannot tell apart.
27
+ //
28
+ // A change is judged by each file's type and content, a link by its target.
29
+ // It cannot stop a shell a guest directs from writing later (after the
30
+ // pending window), or from editing this record: a guest who can drive a
31
+ // Full-access bot on the Cloud already controls the machine.
32
+ import { createHash } from "node:crypto";
33
+ import { existsSync, lstatSync, opendirSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
34
+ import { dirname, join, resolve } from "node:path";
35
+ import { z } from "zod";
36
+ import { writeFileAtomic } from "./atomic.js";
37
+ /** Instruction files an engine reads from its working folder. */
38
+ export const WORKING_FOLDER_FILES = [".mcp.json", ".claude/settings.json", ".claude/settings.local.json", ".claude/CLAUDE.md"];
39
+ /** Folders of skills, agents and commands an engine discovers in its working
40
+ * folder: each entry, and a skill's SKILL.md. */
41
+ export const WORKING_FOLDER_DIRS = [".claude/skills", ".claude/agents", ".claude/commands", ".agents/skills"];
42
+ /** Instruction files an engine reads from its working folder and every
43
+ * folder above it (Claude Code's project memory; Codex's AGENTS.md). */
44
+ export const ANCESTOR_FILES = ["CLAUDE.md", "CLAUDE.local.md", "AGENTS.md", "AGENTS.override.md"];
45
+ /** At most this many entries of one skills, agents or commands folder are
46
+ * read. A fuller folder is judged as a whole: any entry added, removed or
47
+ * renamed in it is a change (see `listing`), and so is any edit to an entry
48
+ * or its SKILL.md (by identity, size and times: see `fullFolder`). */
49
+ export const FOLDER_ENTRY_CAP = 200;
50
+ /** A working folder with more top-level entries than this is not listed;
51
+ * its instruction files are looked up by name instead. */
52
+ const ROOT_ENTRY_CAP = 5_000;
53
+ /** What one path is, for the fingerprint: absent (undefined), a link (by
54
+ * target), a regular file (by content), or something else (by type). Cached
55
+ * by identity, size and change time (which no process can set back), so
56
+ * unchanged logs are not reread. */
57
+ const contentCache = new Map();
58
+ function describe(file) {
59
+ let stat;
60
+ // No exception for the usual case, an absent file: it costs more than the look-up.
61
+ try {
62
+ stat = lstatSync(file, { bigint: true, throwIfNoEntry: false });
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ if (!stat)
68
+ return undefined;
69
+ if (stat.isSymbolicLink()) {
70
+ try {
71
+ return `link:${readlinkSync(file)}`;
72
+ }
73
+ catch {
74
+ return "link:";
75
+ }
76
+ }
77
+ if (!stat.isFile())
78
+ return `other:${stat.mode}`;
79
+ const key = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
80
+ const cached = contentCache.get(file);
81
+ if (cached?.key === key)
82
+ return cached.hash;
83
+ let hash;
84
+ try {
85
+ hash = `file:${createHash("sha256").update(readFileSync(file)).digest("hex")}`;
86
+ }
87
+ catch {
88
+ hash = "file:unreadable";
89
+ }
90
+ if (contentCache.size > 50_000)
91
+ contentCache.clear();
92
+ contentCache.set(file, { key, hash });
93
+ return hash;
94
+ }
95
+ const listingCache = new Map();
96
+ function listing(dir, cap, through = false) {
97
+ let stat;
98
+ try {
99
+ stat = lstatSync(dir, { bigint: true, throwIfNoEntry: false });
100
+ }
101
+ catch {
102
+ return undefined;
103
+ }
104
+ if (through && stat?.isSymbolicLink()) {
105
+ let real;
106
+ try {
107
+ real = realpathSync(dir);
108
+ }
109
+ catch {
110
+ return undefined;
111
+ }
112
+ const target = listing(real, cap);
113
+ return target && { ...target, link: true };
114
+ }
115
+ if (!stat?.isDirectory())
116
+ return undefined;
117
+ const key = `${stat.dev}:${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}`;
118
+ const cacheKey = `${cap}\u0000${dir}`;
119
+ const cached = listingCache.get(cacheKey);
120
+ if (cached?.key === key)
121
+ return cached;
122
+ const names = new Set();
123
+ let overflow = false;
124
+ try {
125
+ const handle = opendirSync(dir);
126
+ try {
127
+ for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
128
+ if (names.size >= cap) {
129
+ overflow = true;
130
+ break;
131
+ }
132
+ names.add(entry.name);
133
+ }
134
+ }
135
+ finally {
136
+ handle.closeSync();
137
+ }
138
+ }
139
+ catch {
140
+ return { key: `${key}:unreadable`, names: new Set(), overflow: true };
141
+ }
142
+ const listed = { key, names, overflow };
143
+ if (listingCache.size > 50_000)
144
+ listingCache.clear();
145
+ listingCache.set(cacheKey, listed);
146
+ return listed;
147
+ }
148
+ function isLink(path) {
149
+ try {
150
+ return lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink() === true;
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ }
156
+ /** A folder too full to list entry by entry (FOLDER_ENTRY_CAP), as one
157
+ * value: its entries, and the identity, size and times of each entry and its
158
+ * SKILL.md, so an edit in place is a change too. Nothing is read. */
159
+ function fullFolder(dir) {
160
+ const all = listing(dir, Infinity, true);
161
+ if (!all)
162
+ return "absent";
163
+ const hash = createHash("sha256").update(all.key);
164
+ const stamp = (file) => {
165
+ let stat;
166
+ try {
167
+ stat = lstatSync(file, { bigint: true, throwIfNoEntry: false });
168
+ }
169
+ catch {
170
+ return "!";
171
+ }
172
+ return stat ? `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}` : "-";
173
+ };
174
+ for (const name of [...all.names].sort())
175
+ hash.update(`${name}\u0000${stamp(join(dir, name))}\u0000${stamp(join(dir, name, "SKILL.md"))}\n`);
176
+ return hash.digest("hex");
177
+ }
178
+ /** Every file that shapes a bot's turns, by name, as `describe` sees it:
179
+ * MEMORY.md, memory/*, memory/log/*, and the instruction files in each
180
+ * working folder and the folders above it. Only names that exist are looked
181
+ * at, a folder that was never created costs one failed look-up, and each
182
+ * folder is listed once per change of its entries. */
183
+ export function memoryFiles(workspace, workingFolders = [], alsoAbove = []) {
184
+ const files = {};
185
+ const add = (name, file) => {
186
+ const described = describe(file);
187
+ if (described !== undefined)
188
+ files[name] = described;
189
+ };
190
+ add("MEMORY.md", join(workspace, "MEMORY.md"));
191
+ add("memory", join(workspace, "memory"));
192
+ for (const name of [...listing(join(workspace, "memory"), Infinity)?.names ?? []].sort())
193
+ if (name !== "log")
194
+ add(`memory/${name}`, join(workspace, "memory", name));
195
+ add("memory/log", join(workspace, "memory", "log"));
196
+ for (const name of [...listing(join(workspace, "memory", "log"), Infinity)?.names ?? []].sort())
197
+ add(`memory/log/${name}`, join(workspace, "memory", "log", name));
198
+ /** The names among `wanted` present in `dir` (all of them, unlisted, when
199
+ * the folder is too full to list). */
200
+ const present = (dir, wanted, listed = listing(dir, ROOT_ENTRY_CAP)) => listed ? wanted.filter((name) => listed.overflow || listed.names.has(name)) : [];
201
+ /** A skills, agents or commands folder (or a link to one: its target is
202
+ * recorded too, and its entries are read through it). */
203
+ const capped = (dir) => {
204
+ const listed = listing(dir, FOLDER_ENTRY_CAP, true);
205
+ if (!listed || listed.link)
206
+ add(dir, dir);
207
+ if (!listed)
208
+ return;
209
+ if (listed.overflow) {
210
+ files[dir] = `overflow:${fullFolder(dir)}`;
211
+ return;
212
+ }
213
+ for (const entry of [...listed.names].sort()) {
214
+ add(join(dir, entry), join(dir, entry));
215
+ add(join(dir, entry, "SKILL.md"), join(dir, entry, "SKILL.md"));
216
+ }
217
+ };
218
+ const listedRoots = new Map();
219
+ const ancestors = new Set();
220
+ for (const folder of new Set([workspace, ...workingFolders].map((dir) => resolve(dir)))) {
221
+ // A working folder that is a link is read through it.
222
+ const top = listing(folder, ROOT_ENTRY_CAP, true);
223
+ if (top) {
224
+ listedRoots.set(folder, top);
225
+ for (const name of present(folder, [".mcp.json", ".claude", ".agents"], top)) {
226
+ const path = join(folder, name);
227
+ if (name === ".mcp.json") {
228
+ add(path, path);
229
+ continue;
230
+ }
231
+ // A link (or a file) in place of the folder is judged by what it
232
+ // is, and a link's files by name through it.
233
+ const inner = listing(path, ROOT_ENTRY_CAP);
234
+ if (!inner)
235
+ add(path, path);
236
+ if (!inner && !isLink(path))
237
+ continue;
238
+ const wanted = name === ".claude" ? ["settings.json", "settings.local.json", "CLAUDE.md", "skills", "agents", "commands"] : ["skills"];
239
+ for (const entry of inner ? present(path, wanted, inner) : wanted) {
240
+ if (entry === "skills" || entry === "agents" || entry === "commands")
241
+ capped(join(path, entry));
242
+ else
243
+ add(join(path, entry), join(path, entry));
244
+ }
245
+ }
246
+ }
247
+ // Up to the first folder already walked: its parents are there too.
248
+ for (let dir = folder; !ancestors.has(dir); dir = dirname(dir)) {
249
+ ancestors.add(dir);
250
+ if (dirname(dir) === dir)
251
+ break;
252
+ }
253
+ }
254
+ // Folders a conversation will work below once it runs (`alsoAbove`): the
255
+ // instruction files there and above reach its first turn.
256
+ for (const folder of alsoAbove) {
257
+ for (let dir = resolve(folder); !ancestors.has(dir); dir = dirname(dir)) {
258
+ ancestors.add(dir);
259
+ if (dirname(dir) === dir)
260
+ break;
261
+ }
262
+ }
263
+ for (const dir of ancestors) {
264
+ const top = listedRoots.get(dir);
265
+ for (const name of top ? present(dir, ANCESTOR_FILES, top) : ANCESTOR_FILES)
266
+ add(join(dir, name), join(dir, name));
267
+ }
268
+ // "memory" and "memory/log" name the folders themselves: only a link
269
+ // swapped in for one counts.
270
+ for (const folder of ["memory", "memory/log"])
271
+ if (files[folder]?.startsWith("other:"))
272
+ delete files[folder];
273
+ return files;
274
+ }
275
+ /** memoryFiles for one bot: its workspace, its own folder, the folder of
276
+ * every conversation that has run, and the folders above the task folders
277
+ * of those that have not yet. A conversation that never ran has no folder
278
+ * to look in (its first turn creates it). A bot that no longer exists has
279
+ * nothing left to judge, and its workspace must not be recreated to look. */
280
+ export function botMemoryFiles(bot, dirs) {
281
+ if (!bot)
282
+ return {};
283
+ const folders = bot.tasks.flatMap((task) => typeof task.cwd === "string" ? [task.cwd] : []);
284
+ return memoryFiles(dirs.workspace(bot.id), [...new Set([...(bot.cwd ? [bot.cwd] : []), ...folders])], [join(dirs.taskWorkspaces, bot.id)]);
285
+ }
286
+ /** One hash over a set of files, for the stored record and the review token. */
287
+ export function fingerprintOf(files) {
288
+ const hash = createHash("sha256");
289
+ for (const name of Object.keys(files).sort())
290
+ hash.update(`${name}\u0000${files[name]}\u0000`);
291
+ return hash.digest("hex");
292
+ }
293
+ /** Back-compat for callers that only need the hash of one workspace. */
294
+ export function memoryFingerprint(workspace, workingFolders = []) {
295
+ return fingerprintOf(memoryFiles(workspace, workingFolders));
296
+ }
297
+ /** What the record keeps of each file: enough to say which ones changed. */
298
+ function digests(files) {
299
+ return Object.fromEntries(Object.entries(files).map(([name, described]) => [name, createHash("sha256").update(described).digest("hex").slice(0, 24)]));
300
+ }
301
+ /** The files that differ between two snapshots (as digests). */
302
+ export function changedFiles(before, after) {
303
+ if (!before)
304
+ return Object.keys(after).sort();
305
+ return [...new Set([...Object.keys(before), ...Object.keys(after)])].filter((name) => before[name] !== after[name]).sort();
306
+ }
307
+ const record = z.object({
308
+ trusted: z.string().regex(/^[a-f0-9]{64}$/),
309
+ files: z.record(z.string().max(4096), z.string().regex(/^[a-f0-9]{24}$/)).optional(),
310
+ flagged: z.literal(true).optional(),
311
+ pending: z.literal(true).optional(),
312
+ }).strict();
313
+ const recordsFile = z.object({
314
+ version: z.literal(1),
315
+ bots: z.record(z.string().max(128), record),
316
+ /** The record could not be read once: the bots that existed then (and
317
+ * have no record since) are flagged. `true` is every bot there is. */
318
+ unknownFlagged: z.union([z.literal(true), z.array(z.string().max(128)).max(100_000)]).optional(),
319
+ }).strict();
320
+ export function createLendingMemory({ file, files: snapshot, knownBots, log = () => { } }) {
321
+ let bots = {};
322
+ // Fail closed: a damaged, linked or oversized record would otherwise wipe
323
+ // every flag. Every bot that existed when it was found damaged starts
324
+ // flagged instead; a bot created later starts clean.
325
+ let unknownFlagged = new Set();
326
+ // Read on first use, when the fleet it may need to name exists.
327
+ let loaded = false;
328
+ const load = () => {
329
+ if (loaded)
330
+ return;
331
+ loaded = true;
332
+ if (!existsSync(file))
333
+ return;
334
+ let damaged = false;
335
+ try {
336
+ const stat = lstatSync(file);
337
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 32_000_000)
338
+ throw new Error("unsafe record");
339
+ const parsed = recordsFile.parse(JSON.parse(readFileSync(file, "utf8")));
340
+ bots = parsed.bots;
341
+ if (parsed.unknownFlagged === true)
342
+ damaged = true;
343
+ else
344
+ unknownFlagged = new Set(parsed.unknownFlagged ?? []);
345
+ }
346
+ catch {
347
+ bots = {};
348
+ damaged = true;
349
+ }
350
+ if (damaged) {
351
+ unknownFlagged = new Set(knownBots().filter((botId) => !bots[botId]));
352
+ log("lending: the record of bots' memory could not be read; every bot's memory needs the owner's review before it can use the lent Mac");
353
+ try {
354
+ save();
355
+ }
356
+ catch { /* flagged in memory for this run; the damaged file says so again next time */ }
357
+ }
358
+ };
359
+ const save = () => writeFileAtomic(file, JSON.stringify({ version: 1, bots, ...(unknownFlagged.size ? { unknownFlagged: [...unknownFlagged] } : {}) }), { mode: 0o600 });
360
+ const set = (botId, next) => {
361
+ const before = JSON.stringify(bots[botId] ?? null);
362
+ bots = { ...bots, [botId]: next };
363
+ const known = unknownFlagged.delete(botId);
364
+ if (known || JSON.stringify(next) !== before)
365
+ save();
366
+ };
367
+ // A snapshot taken this tick is reused for the rest of it by checks that
368
+ // only look (a Mac action, the Memory panel). Anything that starts or ends
369
+ // a judgement (a foreign turn starting or ending, a review, a write for the
370
+ // owner) takes a fresh one.
371
+ const memo = new Map();
372
+ const fresh = (botId) => {
373
+ const files = snapshot(botId);
374
+ const snap = { files: digests(files), hash: fingerprintOf(files) };
375
+ if (!memo.size)
376
+ setImmediate(() => memo.clear());
377
+ memo.set(botId, snap);
378
+ return snap;
379
+ };
380
+ const now = (botId, cached = false) => (cached && memo.get(botId)) || fresh(botId);
381
+ /** A bot seen for the first time: trusted as it is, unless the record was lost. */
382
+ const first = (botId, current, pending) => ({ trusted: current.hash, files: current.files, ...(unknownFlagged.has(botId) ? { flagged: true } : {}), ...(pending ? { pending: true } : {}) });
383
+ const needs = (botId) => bots[botId]?.flagged === true || (!bots[botId] && unknownFlagged.has(botId));
384
+ return {
385
+ /** A turn that is not provably the owner's starts for this bot. What the
386
+ * owner changed before it is adopted first; from here on, changes are
387
+ * judged as someone else's. */
388
+ noteForeignTurn(botId) {
389
+ load();
390
+ const current = bots[botId];
391
+ if (!current) {
392
+ set(botId, first(botId, now(botId), true));
393
+ return;
394
+ }
395
+ if (current.flagged || current.pending) {
396
+ if (!current.pending)
397
+ set(botId, { ...current, pending: true });
398
+ return;
399
+ }
400
+ const snap = now(botId);
401
+ set(botId, { trusted: snap.hash, files: snap.files, pending: true });
402
+ },
403
+ /** Judge the files now. `foreignRunning`: such a turn still runs.
404
+ * `cached`: a check that only looks may reuse this tick's snapshot. */
405
+ reconcile(botId, foreignRunning, opts = {}) {
406
+ load();
407
+ const current = bots[botId];
408
+ // Clearing "pending" ends a judgement: never on a reused snapshot.
409
+ const snap = now(botId, opts.cached === true && !(current?.pending && !foreignRunning));
410
+ if (!current) {
411
+ const created = first(botId, snap, foreignRunning);
412
+ set(botId, created);
413
+ return { changedBySomeoneElse: created.flagged === true };
414
+ }
415
+ if (current.flagged)
416
+ return { changedBySomeoneElse: true };
417
+ if (snap.hash !== current.trusted) {
418
+ if (current.pending) {
419
+ log(`lending: memory of bot ${botId} changed while a conversation the owner did not write was running; its turns cannot use the lent Mac until the owner reviews it`);
420
+ set(botId, { ...current, flagged: true });
421
+ return { changedBySomeoneElse: true };
422
+ }
423
+ set(botId, { trusted: snap.hash, files: snap.files });
424
+ return { changedBySomeoneElse: false };
425
+ }
426
+ if (current.pending && !foreignRunning)
427
+ set(botId, { trusted: current.trusted, ...(current.files ? { files: current.files } : {}) });
428
+ return { changedBySomeoneElse: false };
429
+ },
430
+ /** What the owner reviews: whether it is needed, a token for exactly the
431
+ * files as they are now, and which files differ from the last trusted
432
+ * state. */
433
+ reviewInfo(botId) {
434
+ load();
435
+ const snap = now(botId, true);
436
+ return { needed: needs(botId), token: snap.hash, changed: changedFiles(bots[botId]?.files, snap.files) };
437
+ },
438
+ /** Whether the owner has a change to review (no file access). */
439
+ needsReview(botId) { load(); return needs(botId); },
440
+ /** The owner looked at the memory and accepts it exactly as it was shown
441
+ * (`token`). A token that no longer matches the files is refused. */
442
+ review(botId, foreignRunning, token) {
443
+ load();
444
+ const snap = now(botId);
445
+ if (token !== snap.hash)
446
+ return { ok: false };
447
+ set(botId, { trusted: snap.hash, files: snap.files, ...(foreignRunning ? { pending: true } : {}) });
448
+ return { ok: true };
449
+ },
450
+ /** A write the harness makes on the owner's behalf. Adopted as the
451
+ * owner's when the memory was still exactly as trusted just before it;
452
+ * otherwise left for reconcile to judge. */
453
+ trustedWrite(botId, write) {
454
+ load();
455
+ const current = bots[botId];
456
+ const before = now(botId).hash;
457
+ const result = write();
458
+ const after = now(botId);
459
+ if (!current)
460
+ set(botId, first(botId, after, false));
461
+ else if (!current.flagged && before === current.trusted)
462
+ set(botId, { ...current, trusted: after.hash, files: after.files });
463
+ return result;
464
+ },
465
+ forget(botId) {
466
+ load();
467
+ memo.delete(botId);
468
+ const known = unknownFlagged.delete(botId);
469
+ if (bots[botId]) {
470
+ const next = { ...bots };
471
+ delete next[botId];
472
+ bots = next;
473
+ save();
474
+ }
475
+ else if (known)
476
+ save();
477
+ },
478
+ };
479
+ }
@@ -6,26 +6,39 @@
6
6
  */
7
7
  export class LocalVmIdleTimer {
8
8
  timer = null;
9
+ lastActivityAt = 0;
9
10
  idleMs;
10
11
  isBusy;
11
12
  suspend;
12
13
  constructor(idleMs, isBusy, suspend) {
13
- if (!Number.isFinite(idleMs) || idleMs <= 0)
14
- throw new Error("Local VM idle timeout must be positive");
15
- this.idleMs = idleMs;
14
+ this.idleMs = checkedIdleMs(idleMs);
16
15
  this.isBusy = isBusy;
17
16
  this.suspend = suspend;
18
17
  }
19
18
  touch() {
20
- this.cancel();
21
- this.timer = setTimeout(() => void this.expire(), this.idleMs);
22
- this.timer.unref?.();
19
+ this.lastActivityAt = Date.now();
20
+ this.arm(this.idleMs);
23
21
  }
24
22
  cancel() {
25
23
  if (this.timer)
26
24
  clearTimeout(this.timer);
27
25
  this.timer = null;
28
26
  }
27
+ /** Apply a new idle window. A pending deadline is re-armed against the
28
+ * last activity, so a shorter window takes effect without waiting out the
29
+ * old one and a longer window extends the current one. A cancelled timer
30
+ * stays cancelled. */
31
+ setIdleMs(idleMs) {
32
+ this.idleMs = checkedIdleMs(idleMs);
33
+ if (!this.timer)
34
+ return;
35
+ this.arm(Math.max(0, this.lastActivityAt + this.idleMs - Date.now()));
36
+ }
37
+ arm(delayMs) {
38
+ this.cancel();
39
+ this.timer = setTimeout(() => void this.expire(), delayMs);
40
+ this.timer.unref?.();
41
+ }
29
42
  async expire() {
30
43
  this.timer = null;
31
44
  if (this.isBusy()) {
@@ -42,3 +55,8 @@ export class LocalVmIdleTimer {
42
55
  }
43
56
  }
44
57
  }
58
+ function checkedIdleMs(idleMs) {
59
+ if (!Number.isFinite(idleMs) || idleMs <= 0)
60
+ throw new Error("Local VM idle timeout must be positive");
61
+ return idleMs;
62
+ }
@@ -102,6 +102,9 @@ export class ManagedDesktopProviders {
102
102
  * that happens to be named company.* is never treated as managed. */
103
103
  known = new Set();
104
104
  owns(instanceId) { return this.known.has(instanceId); }
105
+ /** Whether an organisation manages this desktop right now (a connection
106
+ * has been applied and not revoked), whatever its licence state. */
107
+ enrolled() { return this.connection !== null; }
105
108
  apply(raw, force = false) {
106
109
  const connection = parseManagedDesktopConnection(raw, this.now()), nextSignature = instanceSignature(connection);
107
110
  if (!force && nextSignature === this.currentSignature) {
@@ -27,7 +27,7 @@
27
27
  import { spawn } from "node:child_process";
28
28
  import { StringDecoder } from "node:string_decoder";
29
29
  import { CONTROL_REFUSAL_PLAIN, createControlClient } from "./control-client.js";
30
- import { augmentedPath } from "./env-path.js";
30
+ import { augmentedPath, resolveCliSpawn } from "./env-path.js";
31
31
  import { createToolListNormalizer } from "./mcp-tool-schema.js";
32
32
  // 45s of TOTAL silence before the bridge even probes. An MCP session is
33
33
  // legitimately quiet between tool calls and a slow screenshot can take tens
@@ -203,9 +203,11 @@ export function createMcpBridgeInterceptor(options) {
203
203
  };
204
204
  }
205
205
  export function runMcpBridge(options) {
206
- const child = spawn(options.command, options.args, {
206
+ const env = options.env ?? { ...process.env, PATH: augmentedPath() };
207
+ const { command, args } = resolveCliSpawn(options.command, options.args, env);
208
+ const child = spawn(command, args, {
207
209
  shell: false,
208
- env: options.env ?? { ...process.env, PATH: augmentedPath() },
210
+ env,
209
211
  stdio: ["pipe", "pipe", "pipe"],
210
212
  });
211
213
  // docker may exit before it drains stdin; pipe() leaves this error unhandled.
@@ -1,11 +1,14 @@
1
1
  export class McpHttpError extends Error {
2
2
  kind;
3
3
  status;
4
- constructor(kind, message, status) {
4
+ /** The server's `WWW-Authenticate` challenge on a 401: where to sign in. */
5
+ wwwAuthenticate;
6
+ constructor(kind, message, status, wwwAuthenticate) {
5
7
  super(message);
6
8
  this.name = "McpHttpError";
7
9
  this.kind = kind;
8
10
  this.status = status;
11
+ this.wwwAuthenticate = wwwAuthenticate;
9
12
  }
10
13
  }
11
14
  /** Bytes of one response (or one SSE stream) a probe is willing to read. */
@@ -200,7 +203,7 @@ export class RemoteMcpClient {
200
203
  }
201
204
  if (!response.ok) {
202
205
  drain(response);
203
- throw new McpHttpError("status", `HTTP ${response.status}`, response.status);
206
+ throw new McpHttpError("status", `HTTP ${response.status}`, response.status, response.headers.get("www-authenticate") ?? undefined);
204
207
  }
205
208
  const session = response.headers.get("mcp-session-id");
206
209
  if (session)
@@ -265,7 +268,7 @@ export class RemoteMcpClient {
265
268
  .then(async (response) => {
266
269
  if (!response.ok) {
267
270
  drain(response);
268
- throw new McpHttpError("status", `HTTP ${response.status}`, response.status);
271
+ throw new McpHttpError("status", `HTTP ${response.status}`, response.status, response.headers.get("www-authenticate") ?? undefined);
269
272
  }
270
273
  await readSse(response, this.sseAbort.signal, (event) => {
271
274
  if (event.event === "endpoint") {