machine-bridge-mcp 1.1.5 → 1.2.0
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 +11 -0
- package/CODE_OF_CONDUCT.md +24 -0
- package/CONTRIBUTING.md +3 -1
- package/GOVERNANCE.md +50 -0
- package/README.md +26 -0
- package/SUPPORT.md +31 -0
- package/browser-extension/browser-operations.js +1 -1
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +1 -1
- package/docs/ARCHITECTURE.md +14 -17
- package/docs/AUDIT.md +14 -0
- package/docs/ENGINEERING.md +5 -2
- package/docs/PROJECT_STANDARDS.md +3 -2
- package/docs/TESTING.md +4 -2
- package/docs/UPGRADING.md +30 -0
- package/package.json +11 -4
- package/scripts/coverage-check.mjs +24 -9
- package/src/local/agent-context.mjs +7 -148
- package/src/local/agent-contract.mjs +206 -0
- package/src/local/browser-bridge.mjs +30 -281
- package/src/local/browser-extension-protocol.mjs +19 -4
- package/src/local/browser-operation-service.mjs +325 -0
- package/src/local/call-registry.mjs +44 -1
- package/src/local/capability-ranking.mjs +13 -0
- package/src/local/cli.mjs +4 -4
- package/src/local/daemon-process.mjs +1 -1
- package/src/local/full-access-test.mjs +1 -1
- package/src/local/job-runner.mjs +9 -3
- package/src/local/monotonic-deadline.mjs +7 -0
- package/src/local/numbers.mjs +8 -0
- package/src/local/policy.mjs +88 -12
- package/src/local/project-metadata.mjs +7 -1
- package/src/local/records.mjs +6 -0
- package/src/local/runtime-capabilities.mjs +66 -0
- package/src/local/runtime-diagnostics.mjs +91 -0
- package/src/local/runtime-reporting.mjs +113 -0
- package/src/local/runtime.mjs +53 -190
- package/src/local/service-convergence.mjs +1 -1
- package/src/local/service.mjs +1 -1
- package/src/local/state.mjs +1 -1
- package/src/local/windows-service.mjs +1 -1
- package/src/local/worker-health.mjs +1 -1
- package/src/worker/access.ts +4 -4
- package/src/worker/account-admin.ts +2 -2
- package/src/worker/authority.ts +3 -3
- package/src/worker/index.ts +29 -337
- package/src/worker/oauth-controller.ts +334 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/oauth-tokens.ts +2 -2
- package/src/worker/tool-catalog.ts +1 -1
- package/tsconfig.json +2 -1
- package/tsconfig.local.json +27 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampInt, normalizeBrowserAction, normalizeBrowserSelector, normalizeBrowserWait, normalizeFormAction,
|
|
3
|
+
normalizeInputMode, normalizeNavigationWait, normalizeTabCommand, optionalInteger, optionalString,
|
|
4
|
+
validateNavigationUrl,
|
|
5
|
+
} from "./browser-command.mjs";
|
|
6
|
+
|
|
7
|
+
const MAX_SOURCE_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const MAX_FORM_FIELDS = 200;
|
|
9
|
+
const MAX_FIELD_VALUE_CHARS = 128 * 1024;
|
|
10
|
+
const MAX_FORM_VALUE_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
12
|
+
|
|
13
|
+
export class BrowserOperationService {
|
|
14
|
+
constructor({
|
|
15
|
+
authorizeTool,
|
|
16
|
+
ensureStarted,
|
|
17
|
+
request,
|
|
18
|
+
bridgeStatus,
|
|
19
|
+
extensionPath,
|
|
20
|
+
expectedExtensionVersion,
|
|
21
|
+
runProcess,
|
|
22
|
+
readResourceText,
|
|
23
|
+
readResourceBinary,
|
|
24
|
+
}) {
|
|
25
|
+
this.authorizeTool = authorizeTool;
|
|
26
|
+
this.ensureStarted = ensureStarted;
|
|
27
|
+
this.request = request;
|
|
28
|
+
this.bridgeStatus = bridgeStatus;
|
|
29
|
+
this.extensionPath = extensionPath;
|
|
30
|
+
this.expectedExtensionVersion = expectedExtensionVersion;
|
|
31
|
+
this.runProcess = runProcess;
|
|
32
|
+
this.readResourceText = readResourceText;
|
|
33
|
+
this.readResourceBinary = readResourceBinary;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async status(context = {}) {
|
|
37
|
+
this.authorizeTool("browser_status");
|
|
38
|
+
await this.ensureStarted(context);
|
|
39
|
+
const bridge = this.bridgeStatus();
|
|
40
|
+
const extension = bridge.extensionInfo;
|
|
41
|
+
return {
|
|
42
|
+
available: true,
|
|
43
|
+
connected: bridge.extensionConnected,
|
|
44
|
+
broker_role: bridge.brokerRole,
|
|
45
|
+
endpoint: `ws://127.0.0.1:${bridge.port}/extension`,
|
|
46
|
+
pairing_url: `http://127.0.0.1:${bridge.port}/pair`,
|
|
47
|
+
extension_path: this.extensionPath,
|
|
48
|
+
expected_extension_version: this.expectedExtensionVersion,
|
|
49
|
+
extension_protocol: extension?.protocol || null,
|
|
50
|
+
extension_version: extension?.version || "",
|
|
51
|
+
extension_capabilities: extension?.capabilities || [],
|
|
52
|
+
extension_reload_required: bridge.extensionReloadRequired,
|
|
53
|
+
supported_browsers: ["Chrome", "Chromium", "Microsoft Edge", "Brave", "Vivaldi", "other Chromium browsers with Manifest V3"],
|
|
54
|
+
controls_existing_profile: true,
|
|
55
|
+
controls_extension_profile: true,
|
|
56
|
+
launches_browser_process: false,
|
|
57
|
+
launches_separate_automation_profile: false,
|
|
58
|
+
profile_identity_verifiable: false,
|
|
59
|
+
uses_existing_tabs_and_login_state: true,
|
|
60
|
+
source_access: true,
|
|
61
|
+
semantic_snapshot_refs: true,
|
|
62
|
+
actionability_waits: true,
|
|
63
|
+
trusted_input: true,
|
|
64
|
+
input_modes: ["auto", "trusted", "dom"],
|
|
65
|
+
complex_form_fill: true,
|
|
66
|
+
tab_management: true,
|
|
67
|
+
explicit_waits: true,
|
|
68
|
+
screenshots: true,
|
|
69
|
+
restricted_pages: ["browser-internal pages", "extension stores", "some PDF/plugin viewers", "pages blocked by enterprise policy"],
|
|
70
|
+
security: {
|
|
71
|
+
loopback_only: true,
|
|
72
|
+
bearer_pairing_token: true,
|
|
73
|
+
arbitrary_extension_code_from_mcp: false,
|
|
74
|
+
resource_values_returned_to_model: false,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async pair(args = {}, context = {}) {
|
|
80
|
+
this.authorizeTool("pair_browser_extension");
|
|
81
|
+
const status = await this.status(context);
|
|
82
|
+
if (args.open !== false) {
|
|
83
|
+
const command = process.platform === "darwin"
|
|
84
|
+
? { cmd: "open", argv: [status.pairing_url] }
|
|
85
|
+
: process.platform === "win32"
|
|
86
|
+
? { cmd: "cmd.exe", argv: ["/d", "/s", "/c", "start", "", status.pairing_url] }
|
|
87
|
+
: { cmd: "xdg-open", argv: [status.pairing_url] };
|
|
88
|
+
await this.runProcess(command.cmd, command.argv, 30_000, false, 128 * 1024, context);
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
...status,
|
|
92
|
+
opened_pairing_page: args.open !== false,
|
|
93
|
+
setup_steps: [
|
|
94
|
+
"Open the browser extensions page and enable developer mode.",
|
|
95
|
+
"Load the unpacked extension from extension_path once.",
|
|
96
|
+
"Open pairing_url; the installed extension stores the loopback endpoint and token locally.",
|
|
97
|
+
"After upgrades, reload the unpacked extension and accept any newly requested browser permission.",
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async listTabs(args = {}, context = {}) {
|
|
103
|
+
this.authorizeTool("browser_list_tabs");
|
|
104
|
+
return this.request("list_tabs", {
|
|
105
|
+
currentWindow: args.current_window === true,
|
|
106
|
+
includePinned: args.include_pinned !== false,
|
|
107
|
+
}, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async manageTabs(args = {}, context = {}) {
|
|
111
|
+
this.authorizeTool("browser_manage_tabs");
|
|
112
|
+
return this.request("manage_tabs", normalizeTabCommand(args), clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async wait(args = {}, context = {}) {
|
|
116
|
+
this.authorizeTool("browser_wait");
|
|
117
|
+
const params = normalizeBrowserWait(args);
|
|
118
|
+
const conditionTimeoutSeconds = clampInt(args.timeout_seconds, 30, 1, 120);
|
|
119
|
+
return this.request("wait", params, conditionTimeoutSeconds + 5, context);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async getSource(args = {}, context = {}) {
|
|
123
|
+
this.authorizeTool("browser_get_source");
|
|
124
|
+
return this.request("get_source", {
|
|
125
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
126
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
127
|
+
allFrames: args.all_frames === true,
|
|
128
|
+
maxBytes: clampInt(args.max_bytes, 1024 * 1024, 1, MAX_SOURCE_BYTES),
|
|
129
|
+
}, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async inspectPage(args = {}, context = {}) {
|
|
133
|
+
this.authorizeTool("browser_inspect_page");
|
|
134
|
+
return this.request("inspect_page", {
|
|
135
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
136
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
137
|
+
allFrames: args.all_frames !== false,
|
|
138
|
+
maxElements: clampInt(args.max_elements, 300, 1, 1000),
|
|
139
|
+
includeValues: args.include_values === true,
|
|
140
|
+
}, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async act(args = {}, context = {}) {
|
|
144
|
+
this.authorizeTool("browser_action");
|
|
145
|
+
const action = normalizeBrowserAction(args.action);
|
|
146
|
+
const rawUrl = optionalString(args.url, "url", 32768);
|
|
147
|
+
const payload = {
|
|
148
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
149
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
150
|
+
action,
|
|
151
|
+
selector: normalizeBrowserSelector(args.selector, action),
|
|
152
|
+
url: action === "navigate" ? validateNavigationUrl(rawUrl) : "",
|
|
153
|
+
value: null,
|
|
154
|
+
key: optionalString(args.key, "key", 100),
|
|
155
|
+
waitFor: normalizeNavigationWait(args.wait_for),
|
|
156
|
+
inputMode: normalizeInputMode(args.input_mode),
|
|
157
|
+
elementTimeoutMs: clampInt(args.element_timeout_seconds, 10, 1, 60) * 1000,
|
|
158
|
+
};
|
|
159
|
+
if (action !== "navigate" && rawUrl) throw new Error("url is only valid for navigate");
|
|
160
|
+
if (action !== "press" && payload.key) throw new Error("key is only valid for press");
|
|
161
|
+
if (payload.inputMode === "trusted" && !["click", "double_click", "hover", "press", "type_text"].includes(action)) {
|
|
162
|
+
throw new Error("input_mode=trusted supports click, double_click, hover, press, and type_text only");
|
|
163
|
+
}
|
|
164
|
+
if (args.value !== undefined) payload.value = boundedValue(args.value, "value");
|
|
165
|
+
if (args.value_resource !== undefined) {
|
|
166
|
+
if (payload.value !== null) throw new Error("value and value_resource are mutually exclusive");
|
|
167
|
+
payload.value = boundedValue(await this.readResourceText(validateResource(args.value_resource)), "value_resource");
|
|
168
|
+
}
|
|
169
|
+
if (payload.value !== null && !["fill", "select", "press", "type_text"].includes(action)) {
|
|
170
|
+
throw new Error(`value is not valid for browser action '${action}'`);
|
|
171
|
+
}
|
|
172
|
+
const pageAction = !["navigate", "reload", "back", "forward"].includes(action);
|
|
173
|
+
const defaultTimeout = pageAction ? Math.min(120, Math.max(30, payload.elementTimeoutMs / 1000 + 5)) : 30;
|
|
174
|
+
const response = await this.request("action", payload, clampInt(args.timeout_seconds, defaultTimeout, 1, 120), context);
|
|
175
|
+
return {
|
|
176
|
+
...response,
|
|
177
|
+
value_source: args.value_resource !== undefined ? "local-resource" : payload.value === null ? "none" : "mcp-argument",
|
|
178
|
+
value_exposed: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async fillForm(args = {}, context = {}) {
|
|
183
|
+
this.authorizeTool("browser_fill_form");
|
|
184
|
+
if (!Array.isArray(args.fields) || !args.fields.length) throw new Error("fields must be a non-empty array");
|
|
185
|
+
if (args.fields.length > MAX_FORM_FIELDS) throw new Error(`fields contains more than ${MAX_FORM_FIELDS} entries`);
|
|
186
|
+
const fields = [];
|
|
187
|
+
let totalValueBytes = 0;
|
|
188
|
+
for (let index = 0; index < args.fields.length; index += 1) {
|
|
189
|
+
const input = args.fields[index];
|
|
190
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error(`fields[${index}] must be an object`);
|
|
191
|
+
const allowed = new Set(["selector", "value", "value_resource", "action", "sensitive"]);
|
|
192
|
+
for (const key of Object.keys(input)) if (!allowed.has(key)) throw new Error(`unknown fields[${index}] property: ${key}`);
|
|
193
|
+
let value = input.value === undefined ? null : boundedValue(input.value, `fields[${index}].value`);
|
|
194
|
+
if (input.value_resource !== undefined) {
|
|
195
|
+
if (value !== null) throw new Error(`fields[${index}] value and value_resource are mutually exclusive`);
|
|
196
|
+
value = boundedValue(await this.readResourceText(validateResource(input.value_resource)), `fields[${index}].value_resource`);
|
|
197
|
+
}
|
|
198
|
+
if (value !== null) {
|
|
199
|
+
totalValueBytes += Buffer.byteLength(value);
|
|
200
|
+
if (totalValueBytes > MAX_FORM_VALUE_BYTES) throw new Error("form field values exceed 4 MiB total");
|
|
201
|
+
}
|
|
202
|
+
const action = input.action === undefined ? "fill" : normalizeFormAction(input.action);
|
|
203
|
+
if (value === null && !["check", "uncheck", "click"].includes(action)) throw new Error(`fields[${index}] requires value or value_resource`);
|
|
204
|
+
if (value !== null && !["fill", "select"].includes(action)) throw new Error(`fields[${index}] value is not valid for action '${action}'`);
|
|
205
|
+
fields.push({
|
|
206
|
+
selector: normalizeBrowserSelector(input.selector, action),
|
|
207
|
+
value,
|
|
208
|
+
action,
|
|
209
|
+
sensitive: input.sensitive === true || input.value_resource !== undefined,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
|
|
213
|
+
return this.request("fill_form", {
|
|
214
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
215
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
216
|
+
fields,
|
|
217
|
+
submit: args.submit === true,
|
|
218
|
+
submitSelector: args.submit_selector ? normalizeBrowserSelector(args.submit_selector, "click") : null,
|
|
219
|
+
waitFor: normalizeNavigationWait(args.wait_for),
|
|
220
|
+
elementTimeoutMs: elementTimeoutSeconds * 1000,
|
|
221
|
+
}, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async uploadFiles(args = {}, context = {}) {
|
|
225
|
+
this.authorizeTool("browser_upload_files");
|
|
226
|
+
if (!Array.isArray(args.resources) || !args.resources.length || args.resources.length > 8) {
|
|
227
|
+
throw new Error("resources must contain 1 to 8 registered resource names");
|
|
228
|
+
}
|
|
229
|
+
const filenames = optionalStringArray(args.filenames, "filenames", 8, 255);
|
|
230
|
+
const mimeTypes = optionalStringArray(args.mime_types, "mime_types", 8, 200);
|
|
231
|
+
if (filenames.length > args.resources.length || mimeTypes.length > args.resources.length) {
|
|
232
|
+
throw new Error("filenames and mime_types cannot contain more entries than resources");
|
|
233
|
+
}
|
|
234
|
+
const files = [];
|
|
235
|
+
let total = 0;
|
|
236
|
+
for (const raw of args.resources) {
|
|
237
|
+
const name = validateResource(raw);
|
|
238
|
+
const resource = this.readResourceBinary(name);
|
|
239
|
+
total += resource.buffer.length;
|
|
240
|
+
if (total > 5 * 1024 * 1024) throw new Error("browser upload resources exceed 5 MiB total");
|
|
241
|
+
const suppliedFilename = filenames[files.length];
|
|
242
|
+
const derivedFilename = resource.path.split(/[\\/]/).pop() || name;
|
|
243
|
+
files.push({
|
|
244
|
+
filename: normalizeUploadFilename(suppliedFilename || derivedFilename, { derived: !suppliedFilename }),
|
|
245
|
+
mime: normalizeMimeType(mimeTypes[files.length] || "application/octet-stream"),
|
|
246
|
+
data: resource.buffer.toString("base64"),
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
const elementTimeoutSeconds = clampInt(args.element_timeout_seconds, 10, 1, 60);
|
|
250
|
+
const result = await this.request("upload_files", {
|
|
251
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
252
|
+
frameId: optionalInteger(args.frame_id, "frame_id", 0, Number.MAX_SAFE_INTEGER),
|
|
253
|
+
selector: normalizeBrowserSelector(args.selector, "fill"),
|
|
254
|
+
files,
|
|
255
|
+
elementTimeoutMs: elementTimeoutSeconds * 1000,
|
|
256
|
+
}, clampInt(args.timeout_seconds, Math.max(60, elementTimeoutSeconds + 5), 1, 180), context);
|
|
257
|
+
return { ...result, resource_names: args.resources.map(String), resource_contents_exposed: false };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async screenshot(args = {}, context = {}) {
|
|
261
|
+
this.authorizeTool("browser_screenshot");
|
|
262
|
+
const result = await this.request("screenshot", {
|
|
263
|
+
tabId: optionalInteger(args.tab_id, "tab_id", 1, Number.MAX_SAFE_INTEGER),
|
|
264
|
+
format: args.format === "jpeg" ? "jpeg" : "png",
|
|
265
|
+
quality: clampInt(args.quality, 90, 1, 100),
|
|
266
|
+
}, clampInt(args.timeout_seconds, 30, 1, 120), context);
|
|
267
|
+
const data = String(result.data || "");
|
|
268
|
+
const match = /^data:(image\/(?:png|jpeg));base64,([A-Za-z0-9+/=]+)$/.exec(data);
|
|
269
|
+
if (!match) throw new Error("browser extension returned an invalid screenshot");
|
|
270
|
+
return {
|
|
271
|
+
$mcp: {
|
|
272
|
+
content: [{ type: "image", data: match[2], mimeType: match[1] }],
|
|
273
|
+
structuredContent: {
|
|
274
|
+
tab_id: result.tab_id,
|
|
275
|
+
url: result.url,
|
|
276
|
+
title: result.title,
|
|
277
|
+
mime_type: match[1],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function normalizeUploadFilename(value, { derived = false } = {}) {
|
|
285
|
+
let name = String(value || "");
|
|
286
|
+
if (derived) name = name.replace(/[\u0000-\u001f\u007f/\\]+/g, "_").trim();
|
|
287
|
+
if (!name || name === "." || name === ".." || name.length > 255 || /[\u0000-\u001f\u007f/\\]/.test(name)) {
|
|
288
|
+
if (derived) return "upload.bin";
|
|
289
|
+
throw new Error("filenames entries must be safe single-component filenames of at most 255 characters");
|
|
290
|
+
}
|
|
291
|
+
return name;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function normalizeMimeType(value) {
|
|
295
|
+
const mime = String(value || "").trim().toLowerCase();
|
|
296
|
+
if (!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(mime) || mime.length > 200) {
|
|
297
|
+
throw new Error("mime_types entries must be valid media types");
|
|
298
|
+
}
|
|
299
|
+
return mime;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function optionalStringArray(value, label, maxItems, maxLength) {
|
|
303
|
+
if (value === undefined || value === null) return [];
|
|
304
|
+
if (!Array.isArray(value) || value.length > maxItems) throw new Error(`${label} must be an array with at most ${maxItems} entries`);
|
|
305
|
+
return value.map((item, index) => {
|
|
306
|
+
if (typeof item !== "string" || item.includes("\0") || !item.length || item.length > maxLength) {
|
|
307
|
+
throw new Error(`${label}[${index}] must be a non-empty string of at most ${maxLength} characters`);
|
|
308
|
+
}
|
|
309
|
+
return item;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function boundedValue(value, label) {
|
|
314
|
+
const string = String(value);
|
|
315
|
+
if (string.includes("\0") || string.length > MAX_FIELD_VALUE_CHARS) {
|
|
316
|
+
throw new Error(`${label} exceeds the maximum length or contains a NUL byte`);
|
|
317
|
+
}
|
|
318
|
+
return string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function validateResource(value) {
|
|
322
|
+
const name = String(value || "").trim();
|
|
323
|
+
if (!RESOURCE_NAME.test(name)) throw new Error("value_resource is invalid");
|
|
324
|
+
return name;
|
|
325
|
+
}
|
|
@@ -1,24 +1,59 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
import { randomBytes } from "node:crypto";
|
|
2
4
|
import { performance } from "node:perf_hooks";
|
|
3
5
|
import { BridgeError } from "./errors.mjs";
|
|
4
6
|
|
|
7
|
+
/** @typedef {ReturnType<typeof setTimeout>} TimerHandle */
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{
|
|
10
|
+
* id: string,
|
|
11
|
+
* tool: string,
|
|
12
|
+
* origin: string,
|
|
13
|
+
* startedAt: number,
|
|
14
|
+
* deadlineAt: number | null,
|
|
15
|
+
* controller: AbortController,
|
|
16
|
+
* cancelReason: string,
|
|
17
|
+
* timer: TimerHandle | null,
|
|
18
|
+
* }} CallRecord
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* maximum?: unknown,
|
|
23
|
+
* now?: () => number,
|
|
24
|
+
* scheduler?: {setTimeout: typeof setTimeout, clearTimeout: typeof clearTimeout},
|
|
25
|
+
* onCancel?: (record: CallRecord) => void,
|
|
26
|
+
* onFinish?: (record: CallRecord) => void,
|
|
27
|
+
* }} CallRegistryOptions
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{callId?: unknown, tool?: unknown, origin?: unknown, timeoutMs?: unknown}} OpenCallInput
|
|
31
|
+
*/
|
|
32
|
+
/** @typedef {{signal?: AbortSignal, callId?: unknown}} CancellationContext */
|
|
33
|
+
|
|
5
34
|
export class CallRegistry {
|
|
35
|
+
/** @param {CallRegistryOptions} [options] */
|
|
6
36
|
constructor(options = {}) {
|
|
7
37
|
this.maximum = positiveInteger(options.maximum, 16);
|
|
8
38
|
this.now = typeof options.now === "function" ? options.now : () => performance.now();
|
|
9
39
|
this.scheduler = options.scheduler || { setTimeout, clearTimeout };
|
|
10
40
|
this.onCancel = typeof options.onCancel === "function" ? options.onCancel : () => {};
|
|
11
41
|
this.onFinish = typeof options.onFinish === "function" ? options.onFinish : () => {};
|
|
42
|
+
/** @type {Map<string, CallRecord>} */
|
|
12
43
|
this.calls = new Map();
|
|
13
44
|
}
|
|
14
45
|
|
|
46
|
+
/** @param {OpenCallInput} [input] */
|
|
15
47
|
open({ callId = "", tool = "", origin = "local", timeoutMs = 0 } = {}) {
|
|
16
48
|
const id = String(callId || `call_${randomBytes(16).toString("hex")}`);
|
|
17
49
|
if (this.calls.has(id)) throw new BridgeError("conflict", "duplicate in-flight call id");
|
|
18
|
-
if (this.calls.size >= this.maximum)
|
|
50
|
+
if (this.calls.size >= this.maximum) {
|
|
51
|
+
throw new BridgeError("limit_exceeded", `too many concurrent tool calls (${this.maximum})`, { retryable: true });
|
|
52
|
+
}
|
|
19
53
|
const controller = new AbortController();
|
|
20
54
|
const startedAt = this.now();
|
|
21
55
|
const timeout = positiveInteger(timeoutMs, 0);
|
|
56
|
+
/** @type {CallRecord} */
|
|
22
57
|
const record = {
|
|
23
58
|
id,
|
|
24
59
|
tool: String(tool || ""),
|
|
@@ -44,6 +79,7 @@ export class CallRegistry {
|
|
|
44
79
|
});
|
|
45
80
|
}
|
|
46
81
|
|
|
82
|
+
/** @param {unknown} callId @param {unknown} [reason] @param {string} [code] */
|
|
47
83
|
cancel(callId, reason = "cancelled", code = "cancelled") {
|
|
48
84
|
const record = this.calls.get(String(callId || ""));
|
|
49
85
|
if (!record || record.controller.signal.aborted) return false;
|
|
@@ -53,6 +89,7 @@ export class CallRegistry {
|
|
|
53
89
|
return true;
|
|
54
90
|
}
|
|
55
91
|
|
|
92
|
+
/** @param {unknown} callId */
|
|
56
93
|
finish(callId) {
|
|
57
94
|
const id = String(callId || "");
|
|
58
95
|
const record = this.calls.get(id);
|
|
@@ -63,6 +100,7 @@ export class CallRegistry {
|
|
|
63
100
|
return true;
|
|
64
101
|
}
|
|
65
102
|
|
|
103
|
+
/** @param {unknown} origin @param {unknown} [reason] */
|
|
66
104
|
cancelOrigin(origin, reason = "transport disconnected") {
|
|
67
105
|
const expected = String(origin || "");
|
|
68
106
|
let cancelled = 0;
|
|
@@ -73,6 +111,7 @@ export class CallRegistry {
|
|
|
73
111
|
return cancelled;
|
|
74
112
|
}
|
|
75
113
|
|
|
114
|
+
/** @param {unknown} [reason] */
|
|
76
115
|
cancelAll(reason = "runtime stopped") {
|
|
77
116
|
for (const id of [...this.calls.keys()]) {
|
|
78
117
|
this.cancel(id, reason);
|
|
@@ -80,6 +119,7 @@ export class CallRegistry {
|
|
|
80
119
|
}
|
|
81
120
|
}
|
|
82
121
|
|
|
122
|
+
/** @param {unknown} callId */
|
|
83
123
|
context(callId) {
|
|
84
124
|
const record = this.calls.get(String(callId || ""));
|
|
85
125
|
if (!record) return null;
|
|
@@ -93,6 +133,7 @@ export class CallRegistry {
|
|
|
93
133
|
};
|
|
94
134
|
}
|
|
95
135
|
|
|
136
|
+
/** @param {CancellationContext} [context] */
|
|
96
137
|
throwIfCancelled(context = {}) {
|
|
97
138
|
const signal = context.signal || this.calls.get(String(context.callId || ""))?.controller.signal;
|
|
98
139
|
if (!signal?.aborted) return;
|
|
@@ -103,6 +144,7 @@ export class CallRegistry {
|
|
|
103
144
|
|
|
104
145
|
snapshot() {
|
|
105
146
|
const now = this.now();
|
|
147
|
+
/** @type {Record<string, number>} */
|
|
106
148
|
const byOrigin = {};
|
|
107
149
|
let oldestMs = 0;
|
|
108
150
|
for (const call of this.calls.values()) {
|
|
@@ -118,6 +160,7 @@ export class CallRegistry {
|
|
|
118
160
|
}
|
|
119
161
|
}
|
|
120
162
|
|
|
163
|
+
/** @param {unknown} value @param {number} fallback */
|
|
121
164
|
function positiveInteger(value, fallback) {
|
|
122
165
|
const number = Number(value);
|
|
123
166
|
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
const TOKEN_STOP_WORDS = new Set(["the", "and", "for", "with", "from", "this", "that", "use", "using", "into", "of", "to", "a", "an", "in", "on", "is", "are", "or", "及", "和", "的", "了", "在", "用", "使用", "进行", "根据"]);
|
|
2
3
|
const ENGLISH_TOKEN_CANONICAL = new Map([
|
|
3
4
|
["created", "create"], ["creating", "create"], ["creation", "create"], ["creator", "create"],
|
|
@@ -9,6 +10,7 @@ const ENGLISH_TOKEN_CANONICAL = new Map([
|
|
|
9
10
|
["factcheck", "verify"], ["fact-check", "verify"], ["testing", "test"], ["tests", "test"],
|
|
10
11
|
["building", "build"], ["built", "build"], ["deployment", "deploy"], ["deployed", "deploy"],
|
|
11
12
|
]);
|
|
13
|
+
/** @type {ReadonlyArray<readonly [RegExp, readonly string[]]>} */
|
|
12
14
|
const HAN_TOKEN_ALIASES = Object.freeze([
|
|
13
15
|
[/创建|新建|编写/u, ["create", "creator"]],
|
|
14
16
|
[/改进|优化|更新|维护/u, ["improve", "update"]],
|
|
@@ -29,10 +31,14 @@ const HAN_TOKEN_ALIASES = Object.freeze([
|
|
|
29
31
|
[/安全|漏洞/u, ["security", "audit"]],
|
|
30
32
|
]);
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* @param {{name: string, description: string, argv: string[], searchTerms?: string}} command
|
|
36
|
+
*/
|
|
32
37
|
export function commandMatchText(command) {
|
|
33
38
|
return `${command.name} ${command.description} ${command.argv.join(" ")} ${command.searchTerms || ""}`;
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
/** @param {unknown} task @param {unknown} candidate @param {unknown} [identity] */
|
|
36
42
|
export function relevanceScore(task, candidate, identity = "") {
|
|
37
43
|
const taskTokens = tokenize(task);
|
|
38
44
|
const candidateTokens = tokenize(candidate);
|
|
@@ -51,6 +57,10 @@ export function relevanceScore(task, candidate, identity = "") {
|
|
|
51
57
|
return score;
|
|
52
58
|
}
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* @param {unknown} task
|
|
62
|
+
* @param {{commandsAvailable: boolean, commandRelevant: boolean, skillRelevant: boolean}} options
|
|
63
|
+
*/
|
|
54
64
|
export function recommendTools(task, { commandsAvailable, commandRelevant, skillRelevant }) {
|
|
55
65
|
const lower = String(task || "").toLowerCase();
|
|
56
66
|
const tools = ["agent_context"];
|
|
@@ -64,6 +74,7 @@ export function recommendTools(task, { commandsAvailable, commandRelevant, skill
|
|
|
64
74
|
return [...new Set(tools)];
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
/** @param {unknown} value @returns {Set<string>} */
|
|
67
78
|
export function tokenize(value) {
|
|
68
79
|
const text = String(value || "").toLowerCase();
|
|
69
80
|
const tokens = new Set();
|
|
@@ -86,6 +97,7 @@ export function tokenize(value) {
|
|
|
86
97
|
return tokens;
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
/** @param {unknown} value */
|
|
89
101
|
function comparableText(value) {
|
|
90
102
|
return String(value || "")
|
|
91
103
|
.toLowerCase()
|
|
@@ -94,6 +106,7 @@ function comparableText(value) {
|
|
|
94
106
|
.replace(/\s+/g, " ");
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
/** @param {Set<string>} tokens @param {unknown} raw */
|
|
97
110
|
function addToken(tokens, raw) {
|
|
98
111
|
const token = String(raw || "").replace(/^[.-]+|[.-]+$/g, "");
|
|
99
112
|
if (token.length < 2 || TOKEN_STOP_WORDS.has(token)) return;
|
package/src/local/cli.mjs
CHANGED
|
@@ -250,7 +250,7 @@ async function startRemoteRuntime({ args, workspace, state, daemonLock, logger }
|
|
|
250
250
|
let runtime = null;
|
|
251
251
|
try {
|
|
252
252
|
const readiness = await prepareRemoteState({ args, workspace, state, logger });
|
|
253
|
-
runtime = createRemoteRuntime({ args, workspace, state, daemonLock
|
|
253
|
+
runtime = createRemoteRuntime({ args, workspace, state, daemonLock });
|
|
254
254
|
await runtime.start();
|
|
255
255
|
reportRemoteReady(args, state, readiness);
|
|
256
256
|
keepProcessAlive({ daemon: runtime, lock: daemonLock, logger });
|
|
@@ -295,7 +295,7 @@ async function ensureInitialOwnerAccount(state) {
|
|
|
295
295
|
return { ...created.account, password };
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
function createRemoteRuntime({ args, workspace, state, daemonLock
|
|
298
|
+
function createRemoteRuntime({ args, workspace, state, daemonLock }) {
|
|
299
299
|
return new LocalRuntime({
|
|
300
300
|
workerUrl: state.worker.url,
|
|
301
301
|
secret: state.worker.daemonSecret,
|
|
@@ -542,7 +542,7 @@ async function serviceCommand(args) {
|
|
|
542
542
|
const stateRoot = stateRootFromArgs(args);
|
|
543
543
|
const { installAutostart, uninstallAutostart, autostartStatus, startAutostart, stopAutostart } = await import("./service.mjs");
|
|
544
544
|
if (action === "status") {
|
|
545
|
-
const status = await autostartStatus(
|
|
545
|
+
const status = await autostartStatus();
|
|
546
546
|
const state = optionalServiceState(args, stateRoot);
|
|
547
547
|
const workspaceDaemon = state ? inspectWorkspaceDaemon(state) : null;
|
|
548
548
|
console.log(JSON.stringify({
|
|
@@ -878,5 +878,5 @@ function version() {
|
|
|
878
878
|
}
|
|
879
879
|
|
|
880
880
|
function sleep(ms) {
|
|
881
|
-
return new Promise(resolvePromise => setTimeout(resolvePromise, ms));
|
|
881
|
+
return new Promise(resolvePromise => { setTimeout(resolvePromise, ms); });
|
|
882
882
|
}
|
|
@@ -183,7 +183,7 @@ async function waitForJob(manager, jobId, timeoutMs) {
|
|
|
183
183
|
while (!deadline.expired()) {
|
|
184
184
|
const value = manager.read({ job_id: jobId });
|
|
185
185
|
if (TERMINAL_JOB_STATES.has(value.status)) return value;
|
|
186
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
|
186
|
+
await new Promise((resolvePromise) => { setTimeout(resolvePromise, 50); });
|
|
187
187
|
}
|
|
188
188
|
throw new Error("full access managed-job test timed out");
|
|
189
189
|
}
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -71,7 +71,7 @@ async function releaseRecoveryClaim() {
|
|
|
71
71
|
const deadline = createMonotonicDeadline(5000);
|
|
72
72
|
while (!deadline.expired()) {
|
|
73
73
|
if (removeOwnedJsonFileSync(file, { pid: process.pid, token: recoveryLockToken })) return;
|
|
74
|
-
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
|
|
74
|
+
await new Promise((resolvePromise) => { setTimeout(resolvePromise, 10); });
|
|
75
75
|
}
|
|
76
76
|
throw new Error("recovery runner could not verify ownership of the recovery lock");
|
|
77
77
|
}
|
|
@@ -257,7 +257,10 @@ async function runStep(step, index, phase, plan, resourceContext, cancellationAw
|
|
|
257
257
|
|
|
258
258
|
function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captureOutput, captureBudget }) {
|
|
259
259
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
260
|
-
if (cancellationAware && isCancellationRequested())
|
|
260
|
+
if (cancellationAware && isCancellationRequested()) {
|
|
261
|
+
rejectPromise(new JobCancelledError());
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
261
264
|
const child = spawn(argv[0], argv.slice(1), {
|
|
262
265
|
cwd,
|
|
263
266
|
env,
|
|
@@ -300,7 +303,10 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
|
|
|
300
303
|
});
|
|
301
304
|
child.on("error", (error) => finish(() => rejectPromise(error)));
|
|
302
305
|
child.on("close", (code, signal) => finish(() => {
|
|
303
|
-
if (cancellationAware && isCancellationRequested())
|
|
306
|
+
if (cancellationAware && isCancellationRequested()) {
|
|
307
|
+
rejectPromise(new JobCancelledError());
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
304
310
|
resolvePromise({
|
|
305
311
|
code: Number.isInteger(code) ? code : 1,
|
|
306
312
|
signal: signal ? String(signal) : null,
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
1
3
|
import { performance } from "node:perf_hooks";
|
|
2
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @param {number} timeoutMs
|
|
7
|
+
* @param {() => number} [now]
|
|
8
|
+
*/
|
|
3
9
|
export function createMonotonicDeadline(timeoutMs, now = () => performance.now()) {
|
|
4
10
|
const durationMs = Number(timeoutMs);
|
|
5
11
|
if (!Number.isFinite(durationMs) || durationMs < 0) throw new TypeError("timeoutMs must be a finite non-negative number");
|
|
@@ -23,6 +29,7 @@ export function createMonotonicDeadline(timeoutMs, now = () => performance.now()
|
|
|
23
29
|
});
|
|
24
30
|
}
|
|
25
31
|
|
|
32
|
+
/** @param {() => number} now */
|
|
26
33
|
function readSample(now) {
|
|
27
34
|
const value = Number(now());
|
|
28
35
|
if (!Number.isFinite(value)) throw new TypeError("monotonic clock returned a non-finite value");
|
package/src/local/numbers.mjs
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {unknown} value
|
|
5
|
+
* @param {number} fallback
|
|
6
|
+
* @param {number} minimum
|
|
7
|
+
* @param {number} maximum
|
|
8
|
+
*/
|
|
1
9
|
export function clampInteger(value, fallback, minimum, maximum) {
|
|
2
10
|
const parsed = typeof value === "number"
|
|
3
11
|
? value
|