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.
- package/CHANGELOG.md +20 -0
- package/CONTRIBUTING.md +16 -5
- package/README.md +115 -416
- package/SECURITY.md +2 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/AUDIT.md +20 -0
- package/docs/ENGINEERING.md +2 -2
- package/docs/LOGGING.md +1 -1
- package/docs/MULTI_ACCOUNT.md +2 -2
- package/docs/OPERATIONS.md +2 -2
- package/docs/OVERVIEW.md +113 -0
- package/docs/PROJECT_STANDARDS.md +4 -4
- package/docs/RELEASING.md +25 -12
- package/docs/TESTING.md +12 -8
- package/docs/THREAT_MODEL.md +142 -0
- package/package.json +10 -3
- package/scripts/check-plan.mjs +91 -0
- package/scripts/coverage-check.mjs +22 -0
- package/scripts/github-push.mjs +1 -1
- package/scripts/github-release.mjs +1 -1
- package/scripts/local-release-acceptance.mjs +56 -6
- package/scripts/release-acceptance.mjs +15 -3
- package/scripts/release-state.mjs +1 -1
- package/scripts/run-checks.mjs +29 -0
- package/scripts/start-release-candidate.mjs +113 -0
- package/src/local/agent-context-projection.mjs +158 -0
- package/src/local/agent-context.mjs +23 -332
- package/src/local/agent-skill-discovery.mjs +230 -0
- package/src/local/agent-text-file.mjs +41 -0
- package/src/local/browser-bridge-http.mjs +48 -0
- package/src/local/browser-bridge.mjs +48 -222
- package/src/local/browser-broker-routes.mjs +136 -0
- package/src/local/browser-broker-server.mjs +59 -0
- package/src/local/browser-request-registry.mjs +67 -0
- package/src/local/managed-job-lock.mjs +99 -0
- package/src/local/managed-job-projection.mjs +68 -0
- package/src/local/managed-job-runner.mjs +73 -0
- package/src/local/managed-job-storage.mjs +93 -0
- package/src/local/managed-jobs.mjs +12 -297
- package/src/local/relay-call-recovery.mjs +148 -0
- package/src/local/relay-connection.mjs +5 -0
- package/src/local/runtime-paths.mjs +107 -0
- package/src/local/runtime-relay.mjs +89 -0
- package/src/local/runtime-tool-handlers.mjs +66 -0
- package/src/local/runtime.mjs +80 -242
- package/src/local/windows-launcher.mjs +57 -0
- package/src/local/windows-service.mjs +7 -56
- package/src/worker/daemon-sockets.ts +10 -1
- package/src/worker/index.ts +51 -124
- package/src/worker/mcp-jsonrpc.ts +94 -0
- package/src/worker/oauth-authorization-page.ts +70 -0
- package/src/worker/oauth-controller.ts +9 -58
- package/src/worker/pending-call-contract.ts +26 -0
- package/src/worker/pending-calls.ts +41 -25
- package/src/worker/websocket-protocol.ts +24 -0
- 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
|
+
}
|