openmausbot 0.1.91 → 0.1.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/DesktopViewer-CKNR3SEW.js +5 -0
- package/dist/assets/{abnfDiagram-O67JEVCF-yP-w8pRq.js → abnfDiagram-O67JEVCF-Ckrm1I1X.js} +1 -1
- package/dist/assets/{arc-DsivfZEh.js → arc-BBdBuCgB.js} +1 -1
- package/dist/assets/{architectureDiagram-NJMV4G6O-CILw7lzT.js → architectureDiagram-NJMV4G6O-CDdA3Hjs.js} +1 -1
- package/dist/assets/{blockDiagram-BEXU5L5S-DEQUV3HF.js → blockDiagram-BEXU5L5S-AUrsx1YR.js} +1 -1
- package/dist/assets/{c4Diagram-YGBWAQC7-lSXCZoKA.js → c4Diagram-YGBWAQC7-BEqQ0Unh.js} +1 -1
- package/dist/assets/channel-BU94VpxE.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-QCD5bgWP.js → chunk-2Q5K7J3B-CEEuvd4C.js} +1 -1
- package/dist/assets/{chunk-3FUC2YCW-C5-iZJuL.js → chunk-3FUC2YCW-DyANCiHl.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-cxsawMpY.js → chunk-5VM5RSS4-Dx_Slpw9.js} +1 -1
- package/dist/assets/{chunk-GTNCS2PH-DV8tV8H7.js → chunk-GTNCS2PH-Dl7h7iiD.js} +1 -1
- package/dist/assets/{chunk-GWA4HPMP-CJFZyfbW.js → chunk-GWA4HPMP-BLx07SPb.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-BigbMXco.js → chunk-JWPE2WC7-BMxDj3n7.js} +1 -1
- package/dist/assets/{chunk-NETBCI7D-oVxTfpDy.js → chunk-NETBCI7D-CN66PT4H.js} +1 -1
- package/dist/assets/{chunk-WEXAMYUT-CH4BiroW.js → chunk-WEXAMYUT-6HwhsDKw.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-KEDa6gax.js → chunk-XXDRQBXY-gWnLt7SN.js} +1 -1
- package/dist/assets/{classDiagram-v2-NBCMYWYE-BiPjfqVv.js → classDiagram-v2-NBCMYWYE-CMS5l2ea.js} +1 -1
- package/dist/assets/{cose-bilkent-JH36ORCC-Caxew3OY.js → cose-bilkent-JH36ORCC-CV7sQwIG.js} +1 -1
- package/dist/assets/{cynefin-OW5HDTMX-B_Pui369.js → cynefin-OW5HDTMX-CSyl-7vl.js} +1 -1
- package/dist/assets/{cynefinDiagram-VND7K2PF-X64ydIhP.js → cynefinDiagram-VND7K2PF-BCLKL6az.js} +1 -1
- package/dist/assets/{dagre-6A5THRUB-TqYCI1E1.js → dagre-6A5THRUB-DiaZs6S1.js} +1 -1
- package/dist/assets/{diagram-22UHCM2B-TeOt3rAQ.js → diagram-22UHCM2B-BeTtmt3I.js} +1 -1
- package/dist/assets/{diagram-3UASUU5V-D70BLhDA.js → diagram-3UASUU5V-CsdBG-QQ.js} +1 -1
- package/dist/assets/{diagram-ATOU4E4O-BbQDLwUC.js → diagram-ATOU4E4O-BczvdbR1.js} +1 -1
- package/dist/assets/{diagram-CDSNMT55-ChNJZ1u-.js → diagram-CDSNMT55-DHTTTMhi.js} +1 -1
- package/dist/assets/{diagram-MLGK6HIB-DMBl2APa.js → diagram-MLGK6HIB-RTjHyd8z.js} +1 -1
- package/dist/assets/{diagram-MPIPVDR6-CEqDchOp.js → diagram-MPIPVDR6-BDenHEH_.js} +1 -1
- package/dist/assets/{ebnfDiagram-ZINNZB2B-BVDKRp_-.js → ebnfDiagram-ZINNZB2B-BSiRSbtL.js} +1 -1
- package/dist/assets/{elk-276RUBZZ-CRkofO9B.js → elk-276RUBZZ-BCAElss-.js} +1 -1
- package/dist/assets/{erDiagram-OPXOYQCR-DruOMh5e.js → erDiagram-OPXOYQCR-C4_1ZPJe.js} +1 -1
- package/dist/assets/{flowDiagram-KWPJA3E3-8GMQeB7t.js → flowDiagram-KWPJA3E3-Bcb4Z75S.js} +1 -1
- package/dist/assets/{ganttDiagram-FUAMR5RP-ORE9jcsM.js → ganttDiagram-FUAMR5RP-BiPOzxzb.js} +1 -1
- package/dist/assets/{gitGraphDiagram-X574FWY7-BmsedsWh.js → gitGraphDiagram-X574FWY7-B1wtF3ZS.js} +1 -1
- package/dist/assets/index-Biji-bfL.css +1 -0
- package/dist/assets/{index-DKNEM8Q6.js → index-BkYwilkG.js} +1 -1
- package/dist/assets/index-DZlhoES0.js +597 -0
- package/dist/assets/{infoDiagram-VRGFBTTK-E_p7AooJ.js → infoDiagram-VRGFBTTK-A19No71U.js} +1 -1
- package/dist/assets/{ishikawaDiagram-OU5B5YK6-Y-B2mKda.js → ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js} +1 -1
- package/dist/assets/{journeyDiagram-ZHPQQLJL-eA8RELcK.js → journeyDiagram-ZHPQQLJL-DAO0pVan.js} +1 -1
- package/dist/assets/{kanban-definition-PNTS6WVX-DqIL2e8e.js → kanban-definition-PNTS6WVX-NepmKEe2.js} +1 -1
- package/dist/assets/{linear-yC_29R6e.js → linear-bq1PTDTW.js} +1 -1
- package/dist/assets/{mermaid.core-VhEMkSND.js → mermaid.core-s4LTbQle.js} +5 -5
- package/dist/assets/{mindmap-definition-NLK3R4M7-oJ7kyQZy.js → mindmap-definition-NLK3R4M7-LtdbgHFH.js} +1 -1
- package/dist/assets/{pegDiagram-GJSIUBJH-BmfYcY0O.js → pegDiagram-GJSIUBJH-tdbaCxiK.js} +1 -1
- package/dist/assets/{pieDiagram-5QR66LMP-DKvgP7w9.js → pieDiagram-5QR66LMP-DvkbpW8l.js} +1 -1
- package/dist/assets/{quadrantDiagram-O4NWA36T-BEKL3W0L.js → quadrantDiagram-O4NWA36T-Do4KaH-E.js} +1 -1
- package/dist/assets/{railroadDiagram-XR7U4H2S-BEsiqR5s.js → railroadDiagram-XR7U4H2S-BQWULOAs.js} +1 -1
- package/dist/assets/{requirementDiagram-PLB6GJNP-DWBcWvAP.js → requirementDiagram-PLB6GJNP-w9kei1FU.js} +1 -1
- package/dist/assets/{sankeyDiagram-IPEJSGJF-rpZ2_O4s.js → sankeyDiagram-IPEJSGJF-D2gRy0e8.js} +1 -1
- package/dist/assets/{sequenceDiagram-PO4LG4MO-B99HQdJR.js → sequenceDiagram-PO4LG4MO-a2sO5_Eo.js} +1 -1
- package/dist/assets/{sizeCapture-INFHLROL-BjN0qT5T.js → sizeCapture-INFHLROL-CiPJmFEB.js} +1 -1
- package/dist/assets/{stateDiagram-v2-GCMORJYK-BbsVgGsC.js → stateDiagram-v2-GCMORJYK-DAkUUlL7.js} +1 -1
- package/dist/assets/{swimlanes-2SLR337P-DG3gKkeR.js → swimlanes-2SLR337P-DANf2R8Y.js} +1 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +8 -0
- package/dist/assets/{timeline-definition-EJHVYXUP-BTHOdtF_.js → timeline-definition-EJHVYXUP-BKpQmzrT.js} +1 -1
- package/dist/assets/{usecaseDiagram-POWQR4AR-acLSLFwc.js → usecaseDiagram-POWQR4AR-BtI7qLN-.js} +1 -1
- package/dist/assets/{vennDiagram-UO4OBE2U-C5JzNX9G.js → vennDiagram-UO4OBE2U-gXJXrAG_.js} +1 -1
- package/dist/assets/{wardleyDiagram-VNRHLVJA-DL1-PrR_.js → wardleyDiagram-VNRHLVJA-GU8Po5-n.js} +1 -1
- package/dist/assets/{xychartDiagram-PMCCYNJV-Bvr1477p.js → xychartDiagram-PMCCYNJV-BF4J30g6.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/novnc-NOTICE.txt +118 -0
- package/dist-server/cloud-home-start.js +112 -14
- package/dist-server/companion/src/routes.js +24 -1
- package/dist-server/container-mcp.js +201 -16
- package/dist-server/drivers/agents-proxy.js +7 -4
- package/dist-server/enterprise/server/index.js +5 -1
- package/dist-server/index.js +10698 -4737
- package/dist-server/local-computer-proxy.js +133 -8
- package/dist-server/mcp-gate.js +16 -6
- package/dist-server/model-catalog/models-dev.snapshot.json +7299 -0
- package/dist-server/openmausbot.js +4656 -1258
- package/dist-server/pair-cli.js +4656 -1258
- package/dist-server/server/boat.js +5 -5
- package/dist-server/server/browser-engine.js +10 -1
- package/dist-server/server/browser-live.js +46 -2
- package/dist-server/server/browser-runtime.js +74 -8
- package/dist-server/server/browser-tool-shape.js +6 -4
- package/dist-server/server/chief-of-staff.js +1 -1
- package/dist-server/server/cloud-home-start.js +118 -26
- package/dist-server/server/cloud-home.js +39 -3
- package/dist-server/server/cloud-lending.js +74 -10
- package/dist-server/server/cloud-owner.js +196 -0
- package/dist-server/server/cloud-secrets-boot.js +6 -0
- package/dist-server/server/cloud-secrets.js +65 -0
- package/dist-server/server/computer-control.js +6 -0
- package/dist-server/server/config.js +187 -14
- package/dist-server/server/container-computer.js +46 -34
- package/dist-server/server/desktop-viewer-targets.js +26 -0
- package/dist-server/server/digest.js +12 -2
- package/dist-server/server/drivers/acp/core.js +339 -33
- package/dist-server/server/drivers/acp/cursor.js +13 -1
- package/dist-server/server/drivers/acp/opencode-go.js +614 -85
- package/dist-server/server/drivers/acp/quiet-status.js +257 -0
- package/dist-server/server/drivers/acp/qwen.js +18 -0
- package/dist-server/server/drivers/agents-catalog.js +6 -6
- package/dist-server/server/drivers/antigravity-acp.js +69 -11
- package/dist-server/server/drivers/antigravity-temp.js +360 -0
- package/dist-server/server/drivers/antigravity.js +21 -0
- package/dist-server/server/drivers/builtIn.js +2 -0
- package/dist-server/server/drivers/cerebras.js +108 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +20 -3
- package/dist-server/server/drivers/chatgpt-plan-auth.js +481 -0
- package/dist-server/server/drivers/claude.js +329 -37
- package/dist-server/server/drivers/codex.js +228 -18
- package/dist-server/server/drivers/grok.js +2 -2
- package/dist-server/server/drivers/mistral.js +4 -3
- package/dist-server/server/drivers/openai-chat-protocol.js +4 -3
- package/dist-server/server/drivers/openai-chat.js +66 -4
- package/dist-server/server/drivers/openai-compat.js +73 -23
- package/dist-server/server/drivers/prompt-split.js +2 -0
- package/dist-server/server/engine-install.js +46 -12
- package/dist-server/server/env-path.js +21 -5
- package/dist-server/server/harness/registry.js +2 -2
- package/dist-server/server/index.js +1545 -238
- package/dist-server/server/lending-memory.js +479 -0
- package/dist-server/server/local-vm-idle.js +24 -6
- package/dist-server/server/local-vm-stop-reason.js +26 -0
- package/dist-server/server/managed-desktop.js +3 -0
- package/dist-server/server/mcp-bridge.js +5 -3
- package/dist-server/server/mcp-http.js +6 -3
- package/dist-server/server/mcp-oauth-discovery.js +133 -0
- package/dist-server/server/mcp-oauth-store.js +70 -0
- package/dist-server/server/mcp-oauth.js +550 -0
- package/dist-server/server/mcp-probe.js +14 -1
- package/dist-server/server/mcp-registry.js +43 -0
- package/dist-server/server/memory-upkeep.js +94 -84
- package/dist-server/server/model-catalog/catalog.js +374 -0
- package/dist-server/server/model-catalog/presets.js +176 -0
- package/dist-server/server/model-catalog/trim.js +219 -0
- package/dist-server/server/provider-key-check.js +5 -2
- package/dist-server/server/recent-work.js +1 -1
- package/dist-server/server/request-auth.js +23 -0
- package/dist-server/server/routes/antigravity-leftovers.js +45 -0
- package/dist-server/server/routes/bot-memory.js +31 -6
- package/dist-server/server/routes/desktop-viewer.js +238 -0
- package/dist-server/server/routine-requests.js +1 -1
- package/dist-server/server/routines.js +6 -3
- package/dist-server/server/sessions.js +56 -9
- package/dist-server/server/skills.js +25 -10
- package/dist-server/server/store.js +116 -122
- package/dist-server/server/thread-events.js +2 -0
- package/dist-server/server/thread-starters.js +33 -0
- package/dist-server/server/thread-title.js +46 -0
- package/dist-server/server/tts/fish.js +4 -5
- package/dist-server/server/tts/index.js +5 -3
- package/dist-server/server/vps-computer.js +80 -13
- package/dist-server/server/webhooks.js +18 -1
- package/dist-server/server/workspace-backup-policy.js +3 -0
- package/dist-server/server/workspace-backup.js +25 -0
- package/dist-server/server/workspace.js +66 -16
- package/dist-server/shared/approval-mode.js +1 -1
- package/dist-server/shared/redact.js +1 -0
- package/dist-server/shared/tool-name.js +22 -0
- package/dist-server/shared/workspace-backup-client.js +1 -0
- package/dist-server/src/lib/citations.js +177 -0
- package/dist-server/src/lib/composer-attachments.js +7 -1
- package/dist-server/vps-container-mcp.js +202 -16
- package/dist-server/workspace-backup.worker.js +87 -9
- package/enterprise/server/index.js +5 -1
- package/package.json +1 -1
- package/dist/assets/channel-DrfGMu7X.js +0 -1
- package/dist/assets/index-BlnCEcU6.js +0 -588
- package/dist/assets/index-DVxjOvID.css +0 -1
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BCZhxl7e.js +0 -8
|
@@ -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
|
-
|
|
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.
|
|
21
|
-
this.
|
|
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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
5
|
+
import { DATA_DIR } from "./config.js";
|
|
6
|
+
function recordPath(key, dataDir) {
|
|
7
|
+
return join(dataDir, "local-vm-stops", `${createHash("sha256").update(key).digest("hex")}.json`);
|
|
8
|
+
}
|
|
9
|
+
/** Record the runtime's actual finish timestamp. An external stop later gets
|
|
10
|
+
* a different timestamp and must not be misreported as OMB's idle shutdown. */
|
|
11
|
+
export function recordLocalVmIdleStop(key, stoppedAt, dataDir = DATA_DIR) {
|
|
12
|
+
if (!stoppedAt)
|
|
13
|
+
return;
|
|
14
|
+
mkdirSync(join(dataDir, "local-vm-stops"), { recursive: true, mode: 0o700 });
|
|
15
|
+
writeFileAtomic(recordPath(key, dataDir), JSON.stringify({ stoppedAt }), { mode: 0o600 });
|
|
16
|
+
}
|
|
17
|
+
export function localVmStopReason(key, status, dataDir = DATA_DIR) {
|
|
18
|
+
if (status.container !== "stopped" || !status.stopped_at)
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(readFileSync(recordPath(key, dataDir), "utf8")).stoppedAt === status.stopped_at ? "idle" : null;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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") {
|