@frontendxlab/opencode-computer-use 0.1.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/LICENSE +25 -0
- package/README.md +285 -0
- package/SECURITY.md +36 -0
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/bin/opencode-computer-use-repl.js +12 -0
- package/docs/_headers +12 -0
- package/docs/favicon.svg +4 -0
- package/docs/index.html +733 -0
- package/docs/llms-full.txt +146 -0
- package/docs/llms.txt +30 -0
- package/docs/og-card.png +0 -0
- package/docs/robots.txt +7 -0
- package/docs/site.webmanifest +10 -0
- package/docs/sitemap.xml +9 -0
- package/docs/webmcp.js +135 -0
- package/index.js +78 -0
- package/lib/backend.js +190 -0
- package/lib/json-line-peer.mjs +182 -0
- package/lib/options.js +111 -0
- package/lib/output.mjs +212 -0
- package/lib/repl-kernel.mjs +41 -0
- package/lib/repl.mjs +615 -0
- package/lib/sandbox.mjs +291 -0
- package/package.json +57 -0
package/lib/repl.mjs
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { Worker } from "node:worker_threads";
|
|
9
|
+
import { createContext } from "node:vm";
|
|
10
|
+
import { assertSandboxCode, createSandboxBridge, hardenSandboxContext, sanitizeSandboxContext } from "./sandbox.mjs";
|
|
11
|
+
import { backendEnvironment, resolveBackend } from "./backend.js";
|
|
12
|
+
import { optionsFromEnvironment } from "./options.js";
|
|
13
|
+
import { boundToolResult, createOutputCollector, normalizeImage, normalizeNativeImage, outputLimits } from "./output.mjs";
|
|
14
|
+
import { JsonLinePeer } from "./json-line-peer.mjs";
|
|
15
|
+
|
|
16
|
+
export { JsonLinePeer };
|
|
17
|
+
|
|
18
|
+
const require = createRequire(import.meta.url);
|
|
19
|
+
// The js call whose code is running. Timers and promises carry it, so a callback
|
|
20
|
+
// scheduled by a finished call still names that call, not the one running now.
|
|
21
|
+
const evaluationOwner = new AsyncLocalStorage();
|
|
22
|
+
const repl = require("node:repl");
|
|
23
|
+
const { PassThrough } = require("node:stream");
|
|
24
|
+
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
26
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
27
|
+
const MAX_CODE_CHARS = 200_000;
|
|
28
|
+
const MAX_INPUT_LINE_BYTES = 1 * 1024 * 1024;
|
|
29
|
+
const SERVER_INSTRUCTIONS = `UI automation through a persistent JavaScript REPL using the initialized cua API.
|
|
30
|
+
|
|
31
|
+
On the first call after startup or js_reset, bind the requested app with await cua.getApp("Example App"), or call await cua.getState() only when an app inventory is actually needed. App bindings persist across js calls. Batch deterministic actions and the resulting getAXState() in one js call, then verify the returned UI state. Use nodeRepl.write(value) for additional text and await nodeRepl.emitImage(image) for additional images. Ask the user before destructive or externally visible actions such as sending, deleting, or purchasing.`;
|
|
32
|
+
|
|
33
|
+
function jsDescription(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
34
|
+
return `Run JavaScript in a persistent Node.js REPL with top-level await and an initialized asynchronous cua API for Open Computer Use. Use this for all desktop interactions. Bind an app with let app = await cua.getApp("Example App"); then call await app.click(...), await app.typeText(...), and await app.getAXState(). Top-level bindings persist until js_reset, so prefer top-level var for names that may be assigned again. Batch deterministic actions and the resulting state read in one call to reduce round trips. Use nodeRepl.write(value) for extra text and await nodeRepl.emitImage(image) for extra images. If timeout_ms is omitted, execution times out after ${timeoutMs} ms.`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const RESET_DESCRIPTION = `Reset the persistent Open Computer Use JavaScript session and discard all bindings created by prior js calls. The next js call starts with a freshly initialized cua API. This does not close native apps or erase their state.`;
|
|
38
|
+
|
|
39
|
+
const COMPUTER_USE_GUIDANCE = `## Open Computer Use JavaScript API
|
|
40
|
+
|
|
41
|
+
The runtime exposes an asynchronous app-bound API:
|
|
42
|
+
|
|
43
|
+
- \`await cua.getState({ emit? })\`: list current apps.
|
|
44
|
+
- \`await cua.listApps({ emit? })\`: list current apps.
|
|
45
|
+
- \`await cua.getApp(nameOrBundleID)\`: bind an app and emit its initial accessibility state.
|
|
46
|
+
- \`await app.getAXState({ emit?, textLimit?, maxTreeNodes?, maxTreeDepth? })\`
|
|
47
|
+
- \`await app.getScreenshot({ emit? })\`
|
|
48
|
+
- \`await app.getAXStateAndScreenshot(options?)\`
|
|
49
|
+
- \`await app.click(elementIndexOrPoint, { mouseButton?, clickCount?, clickMethod? })\`
|
|
50
|
+
- \`await app.scroll(elementIndex, direction, pages?)\`
|
|
51
|
+
- \`await app.drag([fromX, fromY], [toX, toY])\`
|
|
52
|
+
- \`await app.typeText(text)\`
|
|
53
|
+
- \`await app.pressKey(key)\`
|
|
54
|
+
- \`await app.setValue(elementIndex, value)\`
|
|
55
|
+
- \`await app.performSecondaryAction(elementIndex, action)\`
|
|
56
|
+
|
|
57
|
+
After actions, call \`getAXState()\` in the same js invocation when the next decision depends on the updated UI. Re-derive element indexes from fresh state after navigation or layout changes. Prefer element indexes over coordinates. Open Computer Use keeps its existing local safety gates, including password-manager denial and explicit authorization for global pointer fallbacks.`;
|
|
58
|
+
|
|
59
|
+
function asError(error) {
|
|
60
|
+
if (error instanceof Error) return error;
|
|
61
|
+
// Errors thrown inside the REPL context come from another realm and fail instanceof.
|
|
62
|
+
return new Error(typeof error?.message === "string" ? error.message : String(error));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function safeErrorMessage(error) {
|
|
66
|
+
return asError(error).message
|
|
67
|
+
.replace(/\b(token|secret|password|api[_-]?key|authorization)\s*[:=]\s*(?:bearer|basic)?[^\n\r,;]*/giu, "$1=[redacted]")
|
|
68
|
+
.slice(0, 4_000);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function toolResultText(result) {
|
|
72
|
+
return (result?.content ?? []).filter(item => item?.type === "text").map(item => item.text ?? "").filter(Boolean).join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toolResultImages(result) {
|
|
76
|
+
return (result?.content ?? []).filter(item => item?.type === "image" && typeof item.data === "string");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseListAppMarkers(markers) {
|
|
80
|
+
const app = {};
|
|
81
|
+
for (const marker of markers.split(/,\s*/)) {
|
|
82
|
+
if (marker === "running") app.isRunning = true;
|
|
83
|
+
else if (marker.startsWith("last-used=")) app.lastUsedDate = marker.slice("last-used=".length);
|
|
84
|
+
else if (marker.startsWith("uses=")) {
|
|
85
|
+
const useCount = Number.parseInt(marker.slice("uses=".length), 10);
|
|
86
|
+
if (Number.isSafeInteger(useCount)) app.useCount = useCount;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return app;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The native compatibility surface intentionally returns human-readable text.
|
|
93
|
+
// Normalize its macOS (em dash) and Linux/Windows (double hyphen) renderings
|
|
94
|
+
// into the stable code-first shape used by cua.listApps(). Unknown lines are
|
|
95
|
+
// retained as display names rather than making discovery fail completely.
|
|
96
|
+
export function parseListApps(text) {
|
|
97
|
+
if (!text.trim() || /^No running top-level apps are visible/u.test(text.trim())) return [];
|
|
98
|
+
return text.split(/\r?\n/).map(line => {
|
|
99
|
+
const trimmed = line.trim();
|
|
100
|
+
const match = /^(.*?)\s+(?:\u2014|--|-)\s+(.+?)\s+\[([^\]]*)\]$/u.exec(trimmed);
|
|
101
|
+
if (!match) return { id: trimmed, displayName: trimmed };
|
|
102
|
+
return { id: match[2], displayName: match[1], ...parseListAppMarkers(match[3]) };
|
|
103
|
+
}).filter(app => app.id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function optionsToSnapshotArgs(options = {}) {
|
|
107
|
+
const args = {};
|
|
108
|
+
if (options.textLimit !== undefined) args.text_limit = options.textLimit;
|
|
109
|
+
if (options.maxTreeNodes !== undefined) args.max_tree_nodes = options.maxTreeNodes;
|
|
110
|
+
if (options.maxTreeDepth !== undefined) args.max_tree_depth = options.maxTreeDepth;
|
|
111
|
+
return args;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function optionsToClickArgs(options = {}) {
|
|
115
|
+
const args = {};
|
|
116
|
+
if (options.mouseButton !== undefined) args.mouse_button = options.mouseButton;
|
|
117
|
+
if (options.clickCount !== undefined) args.click_count = options.clickCount;
|
|
118
|
+
if (options.clickMethod !== undefined) args.click_method = options.clickMethod;
|
|
119
|
+
return args;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function createCuaApi(native, activeOutput, assertActive = () => {}) {
|
|
123
|
+
let docsEmitted = false;
|
|
124
|
+
async function call(tool, args = {}) {
|
|
125
|
+
assertActive();
|
|
126
|
+
const result = await native.request("tools/call", { name: tool, arguments: args });
|
|
127
|
+
if (result?.isError) throw new Error(toolResultText(result) || `${tool} failed`);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
async function emitText(text, options = {}) {
|
|
131
|
+
if (options.emit === false || !text) return;
|
|
132
|
+
activeOutput().write(text, "cua.state");
|
|
133
|
+
}
|
|
134
|
+
async function emitImages(images, options = {}) {
|
|
135
|
+
if (options.emit === false) return;
|
|
136
|
+
for (const image of images) await activeOutput().emitImage({ bytes: Buffer.from(image.data, "base64"), mimeType: image.mimeType ?? "image/png" });
|
|
137
|
+
}
|
|
138
|
+
async function emitDocs() {
|
|
139
|
+
if (docsEmitted) return;
|
|
140
|
+
activeOutput().write(COMPUTER_USE_GUIDANCE, "cua.core");
|
|
141
|
+
docsEmitted = true;
|
|
142
|
+
}
|
|
143
|
+
function appBinding(app) {
|
|
144
|
+
return Object.freeze({
|
|
145
|
+
async getAXState(options = {}) {
|
|
146
|
+
const result = await call("get_app_state", { app, ...optionsToSnapshotArgs(options) });
|
|
147
|
+
const text = toolResultText(result);
|
|
148
|
+
await emitText(text, options);
|
|
149
|
+
return text;
|
|
150
|
+
},
|
|
151
|
+
async getScreenshot(options = {}) {
|
|
152
|
+
const result = await call("get_app_state", { app, text_limit: 1 });
|
|
153
|
+
const image = toolResultImages(result)[0];
|
|
154
|
+
if (!image) throw new Error(`Screenshot unavailable for ${app}`);
|
|
155
|
+
const normalized = normalizeNativeImage(image);
|
|
156
|
+
await emitImages([{ data: normalized.data, mimeType: normalized.mimeType }], options);
|
|
157
|
+
return normalized.bytes;
|
|
158
|
+
},
|
|
159
|
+
async getAXStateAndScreenshot(options = {}) {
|
|
160
|
+
const result = await call("get_app_state", { app, ...optionsToSnapshotArgs(options) });
|
|
161
|
+
const state = toolResultText(result);
|
|
162
|
+
const images = toolResultImages(result).map(normalizeNativeImage);
|
|
163
|
+
await emitText(state, options);
|
|
164
|
+
await emitImages(images, options);
|
|
165
|
+
return images[0] ? { state, screenshot: images[0].bytes } : { state };
|
|
166
|
+
},
|
|
167
|
+
async click(target, options = {}) {
|
|
168
|
+
const targetArgs = Array.isArray(target) ? { x: target[0], y: target[1] } : { element_index: target };
|
|
169
|
+
await call("click", { app, ...targetArgs, ...optionsToClickArgs(options) });
|
|
170
|
+
},
|
|
171
|
+
async drag(from, to) { await call("drag", { app, from_x: from[0], from_y: from[1], to_x: to[0], to_y: to[1] }); },
|
|
172
|
+
async pressKey(key) { await call("press_key", { app, key }); },
|
|
173
|
+
async scroll(target, direction, pages = 1) {
|
|
174
|
+
if (Array.isArray(target)) throw new Error("coordinate scroll is not supported by this Open Computer Use runtime");
|
|
175
|
+
await call("scroll", { app, element_index: target, direction, pages });
|
|
176
|
+
},
|
|
177
|
+
async setValue(elementIndex, value) { await call("set_value", { app, element_index: elementIndex, value }); },
|
|
178
|
+
async typeText(text) { await call("type_text", { app, text }); },
|
|
179
|
+
async performSecondaryAction(elementIndex, action) { await call("perform_secondary_action", { app, element_index: elementIndex, action }); },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
const api = {
|
|
183
|
+
async getState(options = {}) {
|
|
184
|
+
await emitDocs();
|
|
185
|
+
const result = await call("list_apps", {});
|
|
186
|
+
const text = toolResultText(result);
|
|
187
|
+
let apps;
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(text);
|
|
190
|
+
apps = Array.isArray(parsed)
|
|
191
|
+
? parsed
|
|
192
|
+
: Array.isArray(parsed?.apps)
|
|
193
|
+
? parsed.apps
|
|
194
|
+
: parseListApps(text);
|
|
195
|
+
} catch {
|
|
196
|
+
apps = parseListApps(text);
|
|
197
|
+
}
|
|
198
|
+
if (options.emit !== false) activeOutput().write(apps, "cua.state");
|
|
199
|
+
return { apps };
|
|
200
|
+
},
|
|
201
|
+
async listApps(options = {}) {
|
|
202
|
+
await emitDocs();
|
|
203
|
+
const state = await api.getState({ emit: false });
|
|
204
|
+
if (options.emit !== false) activeOutput().write(state.apps, "cua.state");
|
|
205
|
+
return state.apps;
|
|
206
|
+
},
|
|
207
|
+
async getApp(app) {
|
|
208
|
+
await emitDocs();
|
|
209
|
+
const result = await call("get_app_state", { app, text_limit: "max" });
|
|
210
|
+
await emitText(toolResultText(result));
|
|
211
|
+
return appBinding(app);
|
|
212
|
+
},
|
|
213
|
+
async rewriteDocumentation() { activeOutput().write(COMPUTER_USE_GUIDANCE, "cua.core"); },
|
|
214
|
+
};
|
|
215
|
+
return Object.freeze(api);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
export class PersistentJavaScriptSession {
|
|
220
|
+
constructor({ native, limits = {} }) {
|
|
221
|
+
this.native = native;
|
|
222
|
+
this.limits = outputLimits(limits);
|
|
223
|
+
this.active = null;
|
|
224
|
+
this.reset();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#currentOutput() {
|
|
228
|
+
const owner = evaluationOwner.getStore();
|
|
229
|
+
if (!owner || owner !== this.active) throw new Error("nodeRepl output is only available while js is executing");
|
|
230
|
+
return owner;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
reset() {
|
|
234
|
+
if (this.active?.timers) {
|
|
235
|
+
for (const timer of this.active.timers) clearTimeout(timer);
|
|
236
|
+
this.active.timers.clear();
|
|
237
|
+
}
|
|
238
|
+
this.active = null;
|
|
239
|
+
this.repl?.close();
|
|
240
|
+
const input = new PassThrough();
|
|
241
|
+
const output = new PassThrough();
|
|
242
|
+
output.resume();
|
|
243
|
+
this.context = createContext(Object.create(null), {
|
|
244
|
+
codeGeneration: { strings: false, wasm: false },
|
|
245
|
+
importModuleDynamically: async () => {
|
|
246
|
+
throw new Error("dynamic import is disabled in the CUA sandbox");
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
// A throw inside evaluated code (sync, or after an await) never reaches the eval
|
|
250
|
+
// callback: the REPL prints it and moves on. Settle the evaluation from the REPL's
|
|
251
|
+
// error path instead (`handleError` on Node 26, a domain on Node 22), but only the
|
|
252
|
+
// call that threw: a late throw from a finished call is dropped, as the REPL would.
|
|
253
|
+
const settle = error => {
|
|
254
|
+
const owner = evaluationOwner.getStore();
|
|
255
|
+
if (owner && owner === this.active) owner.reject(error);
|
|
256
|
+
return "ignore";
|
|
257
|
+
};
|
|
258
|
+
this.repl = repl.start({
|
|
259
|
+
prompt: "",
|
|
260
|
+
input,
|
|
261
|
+
output,
|
|
262
|
+
terminal: false,
|
|
263
|
+
useGlobal: false,
|
|
264
|
+
ignoreUndefined: true,
|
|
265
|
+
breakEvalOnSigint: true,
|
|
266
|
+
handleError: settle,
|
|
267
|
+
context: this.context,
|
|
268
|
+
});
|
|
269
|
+
this.repl.on("error", () => {});
|
|
270
|
+
this.repl._domain?.on("error", settle);
|
|
271
|
+
this.context = this.repl.context;
|
|
272
|
+
sanitizeSandboxContext(this.context);
|
|
273
|
+
hardenSandboxContext(this.context);
|
|
274
|
+
const activeOutput = () => this.#currentOutput();
|
|
275
|
+
const isActive = () => this.active !== null && evaluationOwner.getStore() === this.active;
|
|
276
|
+
const schedule = (callback, delay) => {
|
|
277
|
+
const owner = this.active;
|
|
278
|
+
if (!owner) return -1;
|
|
279
|
+
const timer = setTimeout(() => {
|
|
280
|
+
owner.timers.delete(timer);
|
|
281
|
+
if (this.active === owner) callback();
|
|
282
|
+
}, delay);
|
|
283
|
+
owner.timers.add(timer);
|
|
284
|
+
return timer;
|
|
285
|
+
};
|
|
286
|
+
const cancel = timer => {
|
|
287
|
+
if (timer === -1) return;
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
this.active?.timers.delete(timer);
|
|
290
|
+
};
|
|
291
|
+
createSandboxBridge(
|
|
292
|
+
createCuaApi(this.native, activeOutput, isActive),
|
|
293
|
+
this.context,
|
|
294
|
+
activeOutput,
|
|
295
|
+
isActive,
|
|
296
|
+
schedule,
|
|
297
|
+
cancel,
|
|
298
|
+
);
|
|
299
|
+
this.nodeRepl = this.context.nodeRepl;
|
|
300
|
+
this.cua = this.context.cua;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async run(code, timeoutMs = DEFAULT_TIMEOUT_MS, requestMeta = {}) {
|
|
304
|
+
if (typeof code !== "string" || !code.trim()) throw new Error("js expects non-empty JavaScript source");
|
|
305
|
+
assertSandboxCode(code);
|
|
306
|
+
const output = createOutputCollector(this.limits);
|
|
307
|
+
const active = {
|
|
308
|
+
...output,
|
|
309
|
+
timers: new Set(),
|
|
310
|
+
reject: undefined,
|
|
311
|
+
};
|
|
312
|
+
this.active = active;
|
|
313
|
+
this.requestMeta = requestMeta;
|
|
314
|
+
let timer;
|
|
315
|
+
const enforceTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0;
|
|
316
|
+
try {
|
|
317
|
+
const evaluation = new Promise((resolve, reject) => {
|
|
318
|
+
active.reject = reject;
|
|
319
|
+
evaluationOwner.run(active, () => this.repl.eval(code, this.repl.context, "open-computer-use-repl", (error, result) => error ? reject(error) : resolve(result)));
|
|
320
|
+
});
|
|
321
|
+
const value = enforceTimeout
|
|
322
|
+
? await Promise.race([evaluation, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`js execution timed out after ${timeoutMs} ms; session reset`)), timeoutMs); })])
|
|
323
|
+
: await evaluation;
|
|
324
|
+
if (output.content.length === 0) output.write("(no output)");
|
|
325
|
+
return { content: output.content, isError: false };
|
|
326
|
+
} catch (error) {
|
|
327
|
+
const message = asError(error).message;
|
|
328
|
+
if (message.includes("timed out")) this.reset();
|
|
329
|
+
output.write(`Error: ${message}`);
|
|
330
|
+
return { content: output.content, isError: true };
|
|
331
|
+
} finally {
|
|
332
|
+
clearTimeout(timer);
|
|
333
|
+
for (const scheduled of active.timers) clearTimeout(scheduled);
|
|
334
|
+
active.timers.clear();
|
|
335
|
+
if (this.active === active) this.active = null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Production isolation boundary. JavaScript runs in a worker so a CPU-bound
|
|
341
|
+
// loop can be terminated without hanging the MCP transport. Native tool calls
|
|
342
|
+
// are brokered back to the parent and still execute serially in the native MCP.
|
|
343
|
+
export class WorkerJavaScriptSession {
|
|
344
|
+
constructor({ native, limits = {} }) {
|
|
345
|
+
this.native = native;
|
|
346
|
+
this.limits = outputLimits(limits);
|
|
347
|
+
this.nextId = 1;
|
|
348
|
+
this.activeRequestId = null;
|
|
349
|
+
this.pending = new Map();
|
|
350
|
+
this.runQueue = Promise.resolve();
|
|
351
|
+
this.#start();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#start() {
|
|
355
|
+
const worker = new Worker(new URL("./repl-kernel.mjs", import.meta.url), {
|
|
356
|
+
execArgv: ["--experimental-repl-await"],
|
|
357
|
+
workerData: this.limits,
|
|
358
|
+
});
|
|
359
|
+
this.worker = worker;
|
|
360
|
+
worker.on("message", message => void this.#onMessage(worker, message));
|
|
361
|
+
worker.on("error", error => this.#failWorker(worker, error));
|
|
362
|
+
worker.on("exit", code => { if (code !== 0) this.#failWorker(worker, new Error(`JavaScript kernel exited with code ${code}`)); });
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async #onMessage(worker, message) {
|
|
366
|
+
if (message.type === "result") {
|
|
367
|
+
const pending = this.pending.get(message.id);
|
|
368
|
+
if (!pending || pending.worker !== worker) return;
|
|
369
|
+
this.pending.delete(message.id);
|
|
370
|
+
clearTimeout(pending.timer);
|
|
371
|
+
if (this.activeRequestId === message.id) this.activeRequestId = null;
|
|
372
|
+
pending.resolve(message.result);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (message.type === "native_request") {
|
|
376
|
+
if (message.requestId !== this.activeRequestId || this.activeRequestId === null) {
|
|
377
|
+
if (this.worker === worker) worker.postMessage({ type: "native_response", id: message.id, error: "native request is no longer active" });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const result = await this.native.request(message.method, message.params, message.timeoutMs);
|
|
382
|
+
if (this.worker === worker) worker.postMessage({ type: "native_response", id: message.id, result });
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (this.worker === worker) worker.postMessage({ type: "native_response", id: message.id, error: safeErrorMessage(error) });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#failWorker(worker, error) {
|
|
390
|
+
if (this.worker === worker) this.activeRequestId = null;
|
|
391
|
+
for (const [id, pending] of this.pending) {
|
|
392
|
+
if (pending.worker !== worker) continue;
|
|
393
|
+
clearTimeout(pending.timer);
|
|
394
|
+
pending.resolve({ content: [{ type: "text", text: `Error: ${safeErrorMessage(error)}` }], isError: true });
|
|
395
|
+
this.pending.delete(id);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async reset() {
|
|
400
|
+
this.activeRequestId = null;
|
|
401
|
+
const old = this.worker;
|
|
402
|
+
this.#failWorker(old, new Error("JavaScript session reset"));
|
|
403
|
+
await old.terminate();
|
|
404
|
+
if (this.worker === old) this.#start();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async close() {
|
|
408
|
+
this.activeRequestId = null;
|
|
409
|
+
const old = this.worker;
|
|
410
|
+
this.#failWorker(old, new Error("JavaScript session closed"));
|
|
411
|
+
await old.terminate();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
run(code, timeoutMs = DEFAULT_TIMEOUT_MS, requestMeta = {}) {
|
|
415
|
+
const queued = this.runQueue.then(() => this.#run(code, timeoutMs, requestMeta));
|
|
416
|
+
this.runQueue = queued.catch(() => {});
|
|
417
|
+
return queued;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#run(code, timeoutMs, requestMeta) {
|
|
421
|
+
const id = this.nextId++;
|
|
422
|
+
return new Promise(resolve => {
|
|
423
|
+
const worker = this.worker;
|
|
424
|
+
this.activeRequestId = id;
|
|
425
|
+
const timer = setTimeout(async () => {
|
|
426
|
+
const pending = this.pending.get(id);
|
|
427
|
+
if (!pending || pending.worker !== worker) return;
|
|
428
|
+
this.pending.delete(id);
|
|
429
|
+
if (this.activeRequestId === id) this.activeRequestId = null;
|
|
430
|
+
await worker.terminate();
|
|
431
|
+
if (this.worker === worker) this.#start();
|
|
432
|
+
resolve({ content: [{ type: "text", text: `Error: js execution timed out after ${timeoutMs} ms; session reset` }], isError: true });
|
|
433
|
+
}, timeoutMs);
|
|
434
|
+
this.pending.set(id, { resolve, timer, worker });
|
|
435
|
+
try {
|
|
436
|
+
worker.postMessage({ type: "exec", id, code, timeoutMs, requestMeta });
|
|
437
|
+
} catch (error) {
|
|
438
|
+
clearTimeout(timer);
|
|
439
|
+
this.pending.delete(id);
|
|
440
|
+
if (this.activeRequestId === id) this.activeRequestId = null;
|
|
441
|
+
resolve({ content: [{ type: "text", text: `Error: ${safeErrorMessage(error)}` }], isError: true });
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function resolveNativeCommand(argv = []) {
|
|
448
|
+
const separator = argv.indexOf("--");
|
|
449
|
+
if (separator >= 0) {
|
|
450
|
+
const command = argv[separator + 1];
|
|
451
|
+
if (!command) throw new Error("expected native MCP command after --");
|
|
452
|
+
return { command, args: argv.slice(separator + 2) };
|
|
453
|
+
}
|
|
454
|
+
return resolveBackend({}, process.env);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function isJsonRecord(value) {
|
|
458
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function validRpcId(value) {
|
|
462
|
+
return (typeof value === "string" && value.length <= 128) || (typeof value === "number" && Number.isFinite(value));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function parseRpcRequest(line) {
|
|
466
|
+
let value;
|
|
467
|
+
try {
|
|
468
|
+
value = JSON.parse(line);
|
|
469
|
+
} catch {
|
|
470
|
+
return { error: { id: null, code: -32700, message: "Invalid JSON-RPC payload" } };
|
|
471
|
+
}
|
|
472
|
+
const id = isJsonRecord(value) && validRpcId(value.id) ? value.id : null;
|
|
473
|
+
if (!isJsonRecord(value) || value.jsonrpc !== "2.0" || typeof value.method !== "string" || value.method.length === 0 || value.method.length > 256 || (value.id !== undefined && !validRpcId(value.id)) || (value.params !== undefined && (value.params === null || typeof value.params !== "object"))) {
|
|
474
|
+
return { error: { id, code: -32600, message: "Invalid JSON-RPC request" } };
|
|
475
|
+
}
|
|
476
|
+
return { request: value };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export async function runServer({ command, args }) {
|
|
480
|
+
const options = optionsFromEnvironment(process.env);
|
|
481
|
+
// Actions return a short status instead of a settle + full snapshot the adapter would
|
|
482
|
+
// discard; state is read explicitly by getAXState(). Runtimes without the flag ignore it.
|
|
483
|
+
const native = new JsonLinePeer({
|
|
484
|
+
command,
|
|
485
|
+
args,
|
|
486
|
+
env: { ...backendEnvironment(), OPEN_COMPUTER_USE_ACTION_READ_BACK: "0" },
|
|
487
|
+
});
|
|
488
|
+
let session;
|
|
489
|
+
let buffer = "";
|
|
490
|
+
let requestQueue = Promise.resolve();
|
|
491
|
+
let shuttingDown;
|
|
492
|
+
let stopping = false;
|
|
493
|
+
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
494
|
+
const shutdown = () => {
|
|
495
|
+
if (shuttingDown) return shuttingDown;
|
|
496
|
+
stopping = true;
|
|
497
|
+
const closeSession = session ? session.close() : Promise.resolve();
|
|
498
|
+
shuttingDown = closeSession.catch(() => {}).finally(() => native.close());
|
|
499
|
+
return shuttingDown;
|
|
500
|
+
};
|
|
501
|
+
const enqueue = (line) => {
|
|
502
|
+
if (!line) return;
|
|
503
|
+
requestQueue = requestQueue.then(() => handle(line)).catch(error => console.error(safeErrorMessage(error)));
|
|
504
|
+
};
|
|
505
|
+
const rejectOversizedInput = () => {
|
|
506
|
+
buffer = "";
|
|
507
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Request frame exceeded the input size limit" } });
|
|
508
|
+
void shutdown();
|
|
509
|
+
};
|
|
510
|
+
const onInput = (chunk) => {
|
|
511
|
+
if (stopping) return;
|
|
512
|
+
let offset = 0;
|
|
513
|
+
while (offset < chunk.length) {
|
|
514
|
+
const newline = chunk.indexOf("\n", offset);
|
|
515
|
+
const piece = newline < 0 ? chunk.slice(offset) : chunk.slice(offset, newline);
|
|
516
|
+
if (Buffer.byteLength(buffer, "utf8") + Buffer.byteLength(piece, "utf8") > MAX_INPUT_LINE_BYTES) {
|
|
517
|
+
rejectOversizedInput();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
buffer += piece;
|
|
521
|
+
if (newline < 0) return;
|
|
522
|
+
enqueue(buffer.trim());
|
|
523
|
+
buffer = "";
|
|
524
|
+
offset = newline + 1;
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
await native.initialize();
|
|
530
|
+
session = new WorkerJavaScriptSession({ native, limits: options });
|
|
531
|
+
process.stdin.setEncoding("utf8");
|
|
532
|
+
process.stdin.on("data", onInput);
|
|
533
|
+
process.stdin.on("error", error => {
|
|
534
|
+
console.error(safeErrorMessage(error));
|
|
535
|
+
void shutdown();
|
|
536
|
+
});
|
|
537
|
+
} catch (error) {
|
|
538
|
+
await shutdown();
|
|
539
|
+
throw error;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function handle(line) {
|
|
543
|
+
const parsed = parseRpcRequest(line);
|
|
544
|
+
if (parsed.error) {
|
|
545
|
+
return send({ jsonrpc: "2.0", id: parsed.error.id, error: { code: parsed.error.code, message: parsed.error.message } });
|
|
546
|
+
}
|
|
547
|
+
const { id, method, params = {} } = parsed.request;
|
|
548
|
+
if (id === undefined) {
|
|
549
|
+
if (method === "notifications/turn-ended") {
|
|
550
|
+
try { native.notify(method, params); } catch (error) { console.error(safeErrorMessage(error)); }
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
if (method === "initialize") {
|
|
556
|
+
return send({
|
|
557
|
+
jsonrpc: "2.0",
|
|
558
|
+
id,
|
|
559
|
+
result: {
|
|
560
|
+
protocolVersion: "2025-06-18",
|
|
561
|
+
capabilities: { tools: { listChanged: false } },
|
|
562
|
+
serverInfo: { name: "open-computer-use-repl", version: "1" },
|
|
563
|
+
instructions: SERVER_INSTRUCTIONS,
|
|
564
|
+
},
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
if (method === "ping") return send({ jsonrpc: "2.0", id, result: {} });
|
|
568
|
+
if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: toolDefinitions(options.timeoutMs) } });
|
|
569
|
+
if (method === "tools/call") {
|
|
570
|
+
if (!isJsonRecord(params) || typeof params.name !== "string") throw new Error("tools/call requires a tool name");
|
|
571
|
+
if (params.arguments !== undefined && !isJsonRecord(params.arguments)) throw new Error("tools/call arguments must be an object");
|
|
572
|
+
if (params.name === "js_reset") {
|
|
573
|
+
await session.reset();
|
|
574
|
+
return send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: "Open Computer Use JavaScript session reset" }], isError: false } });
|
|
575
|
+
}
|
|
576
|
+
if (params.name === "js") {
|
|
577
|
+
const requestedTimeoutMs = params.arguments?.timeout_ms;
|
|
578
|
+
const timeoutMs = Number.isInteger(requestedTimeoutMs) && requestedTimeoutMs > 0
|
|
579
|
+
? Math.min(requestedTimeoutMs, options.timeoutMs, MAX_TIMEOUT_MS)
|
|
580
|
+
: options.timeoutMs;
|
|
581
|
+
const code = params.arguments?.code;
|
|
582
|
+
if (typeof code !== "string" || code.length > MAX_CODE_CHARS) {
|
|
583
|
+
throw new Error(`js code must be a string no longer than ${MAX_CODE_CHARS} characters`);
|
|
584
|
+
}
|
|
585
|
+
const result = boundToolResult(await session.run(code, timeoutMs), options);
|
|
586
|
+
return send({ jsonrpc: "2.0", id, result });
|
|
587
|
+
}
|
|
588
|
+
throw new Error(`Unsupported tool: ${params.name}`);
|
|
589
|
+
}
|
|
590
|
+
throw new Error(`Unsupported method: ${method}`);
|
|
591
|
+
} catch (error) {
|
|
592
|
+
send({ jsonrpc: "2.0", id, error: { code: -32603, message: safeErrorMessage(error) } });
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const shutdownSignals = process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
597
|
+
for (const signal of shutdownSignals) {
|
|
598
|
+
process.on(signal, () => { void shutdown().finally(() => process.exit(0)); });
|
|
599
|
+
}
|
|
600
|
+
process.stdin.on("end", () => { void requestQueue.finally(shutdown); });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function toolDefinitions(defaultTimeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
604
|
+
const requestedTimeout = Number.isFinite(defaultTimeoutMs) ? defaultTimeoutMs : DEFAULT_TIMEOUT_MS;
|
|
605
|
+
const timeoutMs = Math.min(Math.max(requestedTimeout, 1_000), MAX_TIMEOUT_MS);
|
|
606
|
+
const annotations = { destructiveHint: true, openWorldHint: true, readOnlyHint: false };
|
|
607
|
+
return [
|
|
608
|
+
{ name: "js", description: jsDescription(timeoutMs), annotations, inputSchema: { type: "object", additionalProperties: false, properties: { code: { type: "string", minLength: 1, maxLength: MAX_CODE_CHARS, description: "JavaScript source to execute with top-level await and the initialized cua API." }, timeout_ms: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS, description: `Optional timeout in milliseconds. Defaults to ${timeoutMs} and is capped at 300000.` }, title: { type: "string", minLength: 1, maxLength: 80, description: "Short user-facing description of what this code is doing." } }, required: ["code"] } },
|
|
609
|
+
{ name: "js_reset", description: RESET_DESCRIPTION, annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true, idempotentHint: true }, inputSchema: { type: "object", additionalProperties: false, properties: {} } },
|
|
610
|
+
];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
614
|
+
runServer(resolveNativeCommand(process.argv.slice(2))).catch(error => { console.error(`open-computer-use-repl: ${safeErrorMessage(error)}`); process.exit(1); });
|
|
615
|
+
}
|