machine-bridge-mcp 1.2.8 → 1.2.10

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 (57) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/CONTRIBUTING.md +16 -5
  3. package/README.md +115 -416
  4. package/SECURITY.md +2 -0
  5. package/browser-extension/manifest.json +2 -2
  6. package/docs/ARCHITECTURE.md +19 -9
  7. package/docs/AUDIT.md +20 -0
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/LOGGING.md +1 -1
  10. package/docs/MULTI_ACCOUNT.md +2 -2
  11. package/docs/OPERATIONS.md +2 -2
  12. package/docs/OVERVIEW.md +113 -0
  13. package/docs/PROJECT_STANDARDS.md +4 -4
  14. package/docs/RELEASING.md +25 -12
  15. package/docs/TESTING.md +12 -8
  16. package/docs/THREAT_MODEL.md +142 -0
  17. package/package.json +10 -3
  18. package/scripts/check-plan.mjs +91 -0
  19. package/scripts/coverage-check.mjs +22 -0
  20. package/scripts/github-push.mjs +1 -1
  21. package/scripts/github-release.mjs +1 -1
  22. package/scripts/local-release-acceptance.mjs +56 -6
  23. package/scripts/release-acceptance.mjs +15 -3
  24. package/scripts/release-state.mjs +1 -1
  25. package/scripts/run-checks.mjs +29 -0
  26. package/scripts/start-release-candidate.mjs +113 -0
  27. package/src/local/agent-context-projection.mjs +158 -0
  28. package/src/local/agent-context.mjs +23 -332
  29. package/src/local/agent-skill-discovery.mjs +230 -0
  30. package/src/local/agent-text-file.mjs +41 -0
  31. package/src/local/browser-bridge-http.mjs +48 -0
  32. package/src/local/browser-bridge.mjs +48 -222
  33. package/src/local/browser-broker-routes.mjs +136 -0
  34. package/src/local/browser-broker-server.mjs +59 -0
  35. package/src/local/browser-request-registry.mjs +67 -0
  36. package/src/local/managed-job-lock.mjs +99 -0
  37. package/src/local/managed-job-projection.mjs +68 -0
  38. package/src/local/managed-job-runner.mjs +73 -0
  39. package/src/local/managed-job-storage.mjs +93 -0
  40. package/src/local/managed-jobs.mjs +12 -297
  41. package/src/local/relay-call-recovery.mjs +148 -0
  42. package/src/local/relay-connection.mjs +5 -0
  43. package/src/local/runtime-paths.mjs +107 -0
  44. package/src/local/runtime-relay.mjs +89 -0
  45. package/src/local/runtime-tool-handlers.mjs +66 -0
  46. package/src/local/runtime.mjs +80 -242
  47. package/src/local/windows-launcher.mjs +57 -0
  48. package/src/local/windows-service.mjs +7 -56
  49. package/src/worker/daemon-sockets.ts +10 -1
  50. package/src/worker/index.ts +51 -124
  51. package/src/worker/mcp-jsonrpc.ts +94 -0
  52. package/src/worker/oauth-authorization-page.ts +70 -0
  53. package/src/worker/oauth-controller.ts +9 -58
  54. package/src/worker/pending-call-contract.ts +26 -0
  55. package/src/worker/pending-calls.ts +41 -25
  56. package/src/worker/websocket-protocol.ts +24 -0
  57. package/tsconfig.local.json +7 -1
@@ -0,0 +1,230 @@
1
+ // @ts-check
2
+ import { lstat, opendir, realpath, stat } from "node:fs/promises";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import { assertAllowedPath, MAX_SKILL_ROOTS } from "./agent-contract.mjs";
5
+ import { sha256 } from "./agent-context-projection.mjs";
6
+ import { readRegularUtf8 } from "./agent-text-file.mjs";
7
+
8
+ export const MAX_SKILL_ENTRY_BYTES = 512 * 1024;
9
+ export const MAX_SKILL_RESULTS = 500;
10
+ export const MAX_SKILL_FILES = 500;
11
+ const MAX_SKILL_SCAN_ENTRIES = 20_000;
12
+ const MAX_SKILL_SCAN_DEPTH = 8;
13
+
14
+ /**
15
+ * @typedef {object} SkillSummary
16
+ * @property {string} id
17
+ * @property {string} name
18
+ * @property {string} description
19
+ * @property {string} entrypoint
20
+ * @property {string} directory
21
+ * @property {string} sourceRoot
22
+ * @property {number} bytes
23
+ * @property {string} sha256
24
+ */
25
+ /**
26
+ * @typedef {object} SkillDiscoveryOptions
27
+ * @property {string[]} skillRoots
28
+ * @property {string} query
29
+ * @property {number} maxResults
30
+ * @property {string} workspace
31
+ * @property {boolean} unrestricted
32
+ * @property {(value: string) => string} displayPath
33
+ * @property {unknown} context
34
+ * @property {(context: unknown) => void} throwIfCancelled
35
+ */
36
+
37
+ /** @param {SkillDiscoveryOptions} options */
38
+ export async function discoverLocalSkills(options) {
39
+ const query = String(options.query || "").trim().toLowerCase();
40
+ /** @type {SkillSummary[]} */
41
+ const skills = [];
42
+ /** @type {Array<{entrypoint: string, message: string}>} */
43
+ const warnings = [];
44
+ const seenEntrypoints = new Set();
45
+ const seenDirectories = new Set();
46
+ let visitedEntries = 0;
47
+ let truncated = false;
48
+
49
+ for (const configuredRoot of options.skillRoots.slice(0, MAX_SKILL_ROOTS)) {
50
+ options.throwIfCancelled(options.context);
51
+ const rootInfo = await lstat(configuredRoot).catch((error) => isMissing(error) ? null : Promise.reject(error));
52
+ if (!rootInfo) continue;
53
+ const root = await realpath(configuredRoot);
54
+ const canonicalRootInfo = await stat(root);
55
+ if (!canonicalRootInfo.isDirectory()) throw new Error(`skill root is not a directory: ${options.displayPath(configuredRoot)}`);
56
+ assertAllowedPath(root, options.workspace, options.unrestricted, "skill root");
57
+ const stack = [{ directory: root, depth: 0 }];
58
+ while (stack.length) {
59
+ options.throwIfCancelled(options.context);
60
+ const current = stack.pop();
61
+ if (!current || seenDirectories.has(current.directory)) continue;
62
+ seenDirectories.add(current.directory);
63
+ visitedEntries += 1;
64
+ if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) {
65
+ truncated = true;
66
+ break;
67
+ }
68
+ const entrypoint = await findSkillEntrypoint(current.directory);
69
+ if (entrypoint) {
70
+ const canonical = await realpath(entrypoint);
71
+ if (!seenEntrypoints.has(canonical)) {
72
+ seenEntrypoints.add(canonical);
73
+ try {
74
+ const summary = await summarizeSkill(canonical, root);
75
+ const haystack = `${summary.id}\n${summary.name}\n${summary.description}\n${summary.entrypoint}`.toLowerCase();
76
+ if (!query || haystack.includes(query)) {
77
+ skills.push(summary);
78
+ if (skills.length >= options.maxResults) {
79
+ truncated = true;
80
+ break;
81
+ }
82
+ }
83
+ } catch (error) {
84
+ if (warnings.length < 100) warnings.push({ entrypoint: canonical, message: boundedMessage(error) });
85
+ }
86
+ }
87
+ continue;
88
+ }
89
+ if (current.depth >= MAX_SKILL_SCAN_DEPTH) continue;
90
+ const handle = await opendir(current.directory);
91
+ for await (const entry of handle) {
92
+ options.throwIfCancelled(options.context);
93
+ visitedEntries += 1;
94
+ if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) {
95
+ truncated = true;
96
+ break;
97
+ }
98
+ const child = join(current.directory, entry.name);
99
+ if (entry.isDirectory()) {
100
+ stack.push({ directory: child, depth: current.depth + 1 });
101
+ } else if (entry.isSymbolicLink()) {
102
+ const target = await realpath(child).catch(() => "");
103
+ if (!target) continue;
104
+ const targetInfo = await stat(target).catch(() => null);
105
+ if (!targetInfo?.isDirectory()) continue;
106
+ assertAllowedPath(target, options.workspace, options.unrestricted, "skill symlink target");
107
+ stack.push({ directory: target, depth: current.depth + 1 });
108
+ }
109
+ }
110
+ if (truncated) break;
111
+ }
112
+ if (truncated && skills.length >= options.maxResults) break;
113
+ if (visitedEntries > MAX_SKILL_SCAN_ENTRIES) break;
114
+ }
115
+
116
+ skills.sort((left, right) => left.name.localeCompare(right.name) || left.entrypoint.localeCompare(right.entrypoint));
117
+ return { skills, warnings, truncated };
118
+ }
119
+
120
+ /** @param {string} root @param {number} maxFiles @param {unknown} context @param {(context: unknown) => void} throwIfCancelled */
121
+ export async function listSkillFiles(root, maxFiles, context, throwIfCancelled) {
122
+ /** @type {Array<{path: string, bytes: number, type: "file" | "symlink"}>} */
123
+ const files = [];
124
+ const stack = [{ directory: root, depth: 0 }];
125
+ let visited = 0;
126
+ let truncated = false;
127
+ while (stack.length) {
128
+ throwIfCancelled(context);
129
+ const current = stack.pop();
130
+ if (!current) continue;
131
+ const handle = await opendir(current.directory);
132
+ for await (const entry of handle) {
133
+ throwIfCancelled(context);
134
+ visited += 1;
135
+ if (visited > MAX_SKILL_SCAN_ENTRIES) {
136
+ truncated = true;
137
+ break;
138
+ }
139
+ const full = join(current.directory, entry.name);
140
+ const rel = relative(root, full).split(sep).join("/");
141
+ if (entry.isDirectory()) {
142
+ if (current.depth < MAX_SKILL_SCAN_DEPTH) stack.push({ directory: full, depth: current.depth + 1 });
143
+ else truncated = true;
144
+ } else if (entry.isFile()) {
145
+ const info = await lstat(full);
146
+ files.push({ path: rel, bytes: info.size, type: "file" });
147
+ } else if (entry.isSymbolicLink()) {
148
+ files.push({ path: rel, bytes: 0, type: "symlink" });
149
+ }
150
+ if (files.length >= maxFiles) {
151
+ truncated = true;
152
+ break;
153
+ }
154
+ }
155
+ if (truncated && (files.length >= maxFiles || visited > MAX_SKILL_SCAN_ENTRIES)) break;
156
+ }
157
+ files.sort((left, right) => left.path.localeCompare(right.path));
158
+ return { files, truncated };
159
+ }
160
+
161
+ /** @param {unknown} content @returns {{name?: string, description?: string}} */
162
+ export function parseSkillMetadata(content) {
163
+ const text = String(content || "");
164
+ if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return {};
165
+ const lines = text.split(/\r?\n/);
166
+ let end = -1;
167
+ for (let index = 1; index < Math.min(lines.length, 200); index += 1) {
168
+ if (lines[index].trim() === "---") {
169
+ end = index;
170
+ break;
171
+ }
172
+ }
173
+ if (end === -1) return {};
174
+ /** @type {{name?: string, description?: string}} */
175
+ const metadata = {};
176
+ for (const line of lines.slice(1, end)) {
177
+ const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line);
178
+ if (!match) continue;
179
+ const key = match[1].toLowerCase();
180
+ if (key === "name" || key === "description") metadata[key] = unquoteScalar(match[2].trim());
181
+ }
182
+ return metadata;
183
+ }
184
+
185
+ /** @param {string} directory */
186
+ async function findSkillEntrypoint(directory) {
187
+ for (const name of ["SKILL.md", "skill.md"]) {
188
+ const candidate = join(directory, name);
189
+ const info = await lstat(candidate).catch((error) => isMissing(error) ? null : Promise.reject(error));
190
+ if (!info) continue;
191
+ if (info.isSymbolicLink()) throw new Error(`skill entrypoint must not be a symbolic link: ${candidate}`);
192
+ if (!info.isFile()) throw new Error(`skill entrypoint is not a regular file: ${candidate}`);
193
+ return candidate;
194
+ }
195
+ return "";
196
+ }
197
+
198
+ /** @param {string} entrypoint @param {string} sourceRoot @returns {Promise<SkillSummary>} */
199
+ async function summarizeSkill(entrypoint, sourceRoot) {
200
+ const content = await readRegularUtf8(entrypoint, MAX_SKILL_ENTRY_BYTES, "skill entrypoint");
201
+ const metadata = parseSkillMetadata(content.text);
202
+ if (!metadata.name || !metadata.description) throw new Error("SKILL.md front matter requires non-empty name and description fields");
203
+ return {
204
+ id: `skill_${sha256(entrypoint).slice(0, 16)}`,
205
+ name: metadata.name.slice(0, 200),
206
+ description: metadata.description.slice(0, 1000),
207
+ entrypoint,
208
+ directory: dirname(entrypoint),
209
+ sourceRoot,
210
+ bytes: content.bytes,
211
+ sha256: sha256(content.text),
212
+ };
213
+ }
214
+
215
+ /** @param {unknown} error */
216
+ function boundedMessage(error) {
217
+ const message = error instanceof Error ? error.message : String(error || "invalid local skill");
218
+ return message.replace(/[\r\n]+/g, " ").slice(0, 1000);
219
+ }
220
+
221
+ /** @param {string} value */
222
+ function unquoteScalar(value) {
223
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) return value.slice(1, -1);
224
+ return value;
225
+ }
226
+
227
+ /** @param {unknown} error */
228
+ function isMissing(error) {
229
+ return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
230
+ }
@@ -0,0 +1,41 @@
1
+ // @ts-check
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, open } from "node:fs/promises";
4
+
5
+ /** @param {string} filePath @param {number} maxBytes @param {string} label */
6
+ export async function readOptionalRegularUtf8(filePath, maxBytes, label) {
7
+ const info = await lstat(filePath).catch((error) => isMissing(error) ? null : Promise.reject(error));
8
+ if (!info) return null;
9
+ if (info.isSymbolicLink()) throw new Error(`${label} must not be a symbolic link: ${filePath}`);
10
+ if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);
11
+ return readRegularUtf8(filePath, maxBytes, label);
12
+ }
13
+
14
+ /** @param {string} filePath @param {number} maxBytes @param {string} label */
15
+ export async function readRegularUtf8(filePath, maxBytes, label) {
16
+ const handle = await open(filePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
17
+ try {
18
+ const info = await handle.stat();
19
+ if (!info.isFile()) throw new Error(`${label} is not a regular file: ${filePath}`);
20
+ if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
21
+ const buffer = Buffer.alloc(info.size);
22
+ let offset = 0;
23
+ while (offset < buffer.length) {
24
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
25
+ if (!bytesRead) break;
26
+ offset += bytesRead;
27
+ }
28
+ try {
29
+ return { text: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)), bytes: offset };
30
+ } catch {
31
+ throw new Error(`${label} is not valid UTF-8 text: ${filePath}`);
32
+ }
33
+ } finally {
34
+ await handle.close();
35
+ }
36
+ }
37
+
38
+ /** @param {unknown} error */
39
+ function isMissing(error) {
40
+ return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT";
41
+ }
@@ -0,0 +1,48 @@
1
+ import { EXPECTED_EXTENSION_VERSION } from "./browser-extension-protocol.mjs";
2
+ import { isAllowedLoopbackHost, pairingHtml, securityHeaders, sendJson } from "./browser-pairing-store.mjs";
3
+
4
+ export function handleBrowserBridgeHttp(request, response, {
5
+ port,
6
+ token,
7
+ extensionConnected,
8
+ extensionStatusInfo,
9
+ extensionReloadRequired,
10
+ }) {
11
+ const host = String(request.headers.host || "");
12
+ if (!isAllowedLoopbackHost(host, port)) {
13
+ response.writeHead(403, securityHeaders("text/plain; charset=utf-8"));
14
+ response.end("Forbidden\n");
15
+ return;
16
+ }
17
+ const url = new URL(request.url || "/", `http://${host}`);
18
+ if (request.method !== "GET") {
19
+ response.writeHead(405, { allow: "GET", "cache-control": "no-store" }).end();
20
+ return;
21
+ }
22
+ if (url.pathname === "/healthz") {
23
+ const extension = extensionStatusInfo();
24
+ sendJson(response, {
25
+ ok: true,
26
+ connected: extensionConnected(),
27
+ broker: "machine-bridge-browser",
28
+ expected_extension_version: EXPECTED_EXTENSION_VERSION,
29
+ extension_protocol: extension?.protocol || null,
30
+ extension_version: extension?.version || "",
31
+ extension_capabilities: extension?.capabilities || [],
32
+ extension_reload_required: extensionReloadRequired(),
33
+ controls_existing_profile: true,
34
+ controls_extension_profile: true,
35
+ machine_bridge_launches_browser: false,
36
+ profile_identity_verifiable: false,
37
+ });
38
+ return;
39
+ }
40
+ if (url.pathname === "/pair") {
41
+ const html = pairingHtml(port, token);
42
+ response.writeHead(200, securityHeaders("text/html; charset=utf-8"));
43
+ response.end(html);
44
+ return;
45
+ }
46
+ response.writeHead(404, securityHeaders("text/plain; charset=utf-8"));
47
+ response.end("Not found\n");
48
+ }