@frontendxlab/opencode-computer-use 0.1.0 → 0.1.1
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/README.md +26 -0
- package/SECURITY.md +5 -0
- package/docs/index.html +14 -3
- package/docs/llms-full.txt +20 -15
- package/docs/llms.txt +2 -0
- package/lib/json-line-peer.mjs +57 -11
- package/lib/options.js +54 -0
- package/lib/repl-kernel.mjs +6 -1
- package/lib/repl.mjs +46 -17
- package/lib/sandbox.mjs +80 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -141,12 +141,33 @@ automatic plugin discovery. That loads the session twice.
|
|
|
141
141
|
| `command` | none | Required for `custom`; a native MCP executable followed by argv. |
|
|
142
142
|
| `serverName` | `cua_repl` | MCP server name. |
|
|
143
143
|
| `override` | `false` | Replace an existing MCP server with the same name. |
|
|
144
|
+
| `safetyMode` | `full` | `full` or `read-only`; read-only blocks click, drag, key, scroll, text, value, and secondary actions. |
|
|
145
|
+
| `appAccess` | allow all | Optional `default`, `allow`, and `deny` rules matched against app names and IDs. |
|
|
144
146
|
| `timeoutMs` | `30000` | Default and maximum JavaScript evaluation timeout. |
|
|
145
147
|
| `disabled` | `false` | Register the server disabled. |
|
|
146
148
|
| `maxTextChars` | `200000` | Aggregate text budget for one tool result. |
|
|
147
149
|
| `maxImageBytes` | `2097152` | Aggregate image payload budget for one tool result. |
|
|
148
150
|
| `allowGlobalPointerFallbacks` | `false` | Explicitly allow native global pointer fallback for coordinate clicks and drags. |
|
|
149
151
|
|
|
152
|
+
For a deny-by-default desktop policy, configure the adapter explicitly:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"safetyMode": "read-only",
|
|
157
|
+
"appAccess": {
|
|
158
|
+
"default": "deny",
|
|
159
|
+
"allow": ["com.apple.TextEdit", "Text"],
|
|
160
|
+
"deny": ["com.apple.Safari"]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
App rules are exact, case-insensitive matches against app names and IDs. They
|
|
166
|
+
are defense in depth and do not replace macOS Accessibility and Screen Recording,
|
|
167
|
+
Linux AT-SPI, Windows UIA, or OpenCode approval settings. The read-only mode
|
|
168
|
+
also changes the MCP annotations for `js` so hosts can apply their normal
|
|
169
|
+
read-only approval policy.
|
|
170
|
+
|
|
150
171
|
The proxy reads corresponding `OPENCODE_COMPUTER_USE_*` environment variables
|
|
151
172
|
when launched directly.
|
|
152
173
|
|
|
@@ -242,6 +263,11 @@ the model-facing session. The native
|
|
|
242
263
|
`run_shell` operation is not part of the Codex-style `cua` API. Do not add an
|
|
243
264
|
arbitrary shell escape to the JavaScript session.
|
|
244
265
|
|
|
266
|
+
The adapter bounds persistent app bindings and output items, sends a native
|
|
267
|
+
cancellation notification when a request times out, and sends a best-effort
|
|
268
|
+
`notifications/turn-ended` message during shutdown. These are reliability and
|
|
269
|
+
cleanup safeguards, not a replacement for host approvals.
|
|
270
|
+
|
|
245
271
|
## Documentation WebMCP preview
|
|
246
272
|
|
|
247
273
|
The Cloudflare Pages documentation includes a progressive, read-only WebMCP
|
package/SECURITY.md
CHANGED
|
@@ -16,6 +16,11 @@ sandbox.
|
|
|
16
16
|
imports, code generation, and dynamic host objects are unavailable in the
|
|
17
17
|
model-facing context.
|
|
18
18
|
- Native calls are serialized through the persistent session.
|
|
19
|
+
- `safetyMode: "read-only"` blocks mutating CUA methods before native execution.
|
|
20
|
+
- `appAccess` can deny apps by exact name or ID before native access.
|
|
21
|
+
- App bindings, output items, text, images, and native frames are bounded.
|
|
22
|
+
- Native timeouts send a cancellation notification; shutdown sends a best-effort
|
|
23
|
+
turn-ended notification.
|
|
19
24
|
- Desktop environment variables are allowlisted.
|
|
20
25
|
- Screenshot and text results remain local MCP content.
|
|
21
26
|
|
package/docs/index.html
CHANGED
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"name": "OpenCode Computer Use",
|
|
62
62
|
"description": "A dual-generation OpenCode plugin for local desktop control through a persistent cua_repl JavaScript session.",
|
|
63
63
|
"url": "https://opencode-computer-use.pages.dev/",
|
|
64
|
-
"softwareVersion": "0.1.
|
|
64
|
+
"softwareVersion": "0.1.1",
|
|
65
65
|
"datePublished": "2026-09-25",
|
|
66
66
|
"dateModified": "2026-09-25",
|
|
67
67
|
"isAccessibleForFree": true,
|
|
@@ -548,6 +548,12 @@ await chrome.getAXState({ textLimit: 12000 });</code></pre><button class="copy"
|
|
|
548
548
|
<div class="code"><pre><code id="options-json">{
|
|
549
549
|
"backend": "auto",
|
|
550
550
|
"serverName": "cua_repl",
|
|
551
|
+
"safetyMode": "full",
|
|
552
|
+
"appAccess": {
|
|
553
|
+
"default": "allow",
|
|
554
|
+
"allow": [],
|
|
555
|
+
"deny": []
|
|
556
|
+
},
|
|
551
557
|
"timeoutMs": 30000,
|
|
552
558
|
"maxTextChars": 200000,
|
|
553
559
|
"maxImageBytes": 2097152,
|
|
@@ -556,6 +562,8 @@ await chrome.getAXState({ textLimit: 12000 });</code></pre><button class="copy"
|
|
|
556
562
|
<ul class="cards">
|
|
557
563
|
<li><strong>backend</strong><span><code>auto</code>, <code>native</code>, or a fixed <code>custom</code> command.</span></li>
|
|
558
564
|
<li><strong>serverName</strong><span>Rename the generated MCP server when a project needs isolation.</span></li>
|
|
565
|
+
<li><strong>safetyMode</strong><span><code>full</code> or <code>read-only</code>. Read-only blocks mutating CUA methods and changes MCP annotations.</span></li>
|
|
566
|
+
<li><strong>appAccess</strong><span>Optional exact-name and ID allow or deny rules, evaluated before native access.</span></li>
|
|
559
567
|
<li><strong>timeoutMs</strong><span>Bounds JavaScript evaluation. A timeout resets the worker session.</span></li>
|
|
560
568
|
<li><strong>maxTextChars</strong><span>Aggregate text budget for one tool result.</span></li>
|
|
561
569
|
<li><strong>maxImageBytes</strong><span>Aggregate image budget for one tool result.</span></li>
|
|
@@ -601,7 +609,10 @@ nodeRepl.write(state);</code></pre><button class="copy" type="button" data-copy=
|
|
|
601
609
|
<li><strong>No arbitrary shell</strong><span>The model-facing API does not expose <code>run_shell</code> or a host command escape.</span></li>
|
|
602
610
|
<li><strong>Restricted VM</strong><span>Filesystem, process, fetch, dynamic import, code generation, and host built-ins are blocked.</span></li>
|
|
603
611
|
<li><strong>Worker isolation</strong><span>CPU-bound JavaScript can be terminated without wedging the MCP server.</span></li>
|
|
604
|
-
<li><strong>
|
|
612
|
+
<li><strong>Read-only mode</strong><span>An opt-in safety profile blocks clicks, keys, text, scroll, drags, and secondary actions before native execution.</span></li>
|
|
613
|
+
<li><strong>App policy</strong><span>Exact-name and ID allow or deny rules provide adapter-level defense in depth.</span></li>
|
|
614
|
+
<li><strong>Bounded transport</strong><span>Input frames, output text, images, content items, app bindings, and native responses are bounded.</span></li>
|
|
615
|
+
<li><strong>Lifecycle cleanup</strong><span>Timeouts send native cancellation, and shutdown sends a best-effort turn-ended notification.</span></li>
|
|
605
616
|
<li><strong>Approval preserving</strong><span>Consequential actions still pass through OpenCode and OS permission boundaries.</span></li>
|
|
606
617
|
<li><strong>Safe defaults</strong><span>Global pointer fallback is disabled until explicitly enabled.</span></li>
|
|
607
618
|
</ul>
|
|
@@ -635,7 +646,7 @@ nodeRepl.write(state);</code></pre><button class="copy" type="button" data-copy=
|
|
|
635
646
|
<strong>Surface map</strong>
|
|
636
647
|
<p>Plugin: <code>opencode-computer-use</code><br>MCP: <code>cua_repl</code><br>Tools: <code>js</code>, <code>js_reset</code></p>
|
|
637
648
|
<strong>Default policy</strong>
|
|
638
|
-
<p>Local stdio only. Global pointer fallback off. Native OS permissions enforced.</p>
|
|
649
|
+
<p>Local stdio only. Global pointer fallback off. Native OS permissions enforced. Read-only and app policies are available as adapter defense in depth.</p>
|
|
639
650
|
<strong>Updated</strong>
|
|
640
651
|
<p>Published September 25, 2026. Updated September 25, 2026.</p>
|
|
641
652
|
<strong>WebMCP</strong>
|
package/docs/llms-full.txt
CHANGED
|
@@ -68,14 +68,17 @@ state.
|
|
|
68
68
|
5. Options
|
|
69
69
|
backend (default auto: auto, native, or custom); command (required for
|
|
70
70
|
custom: fixed native MCP executable plus argv, no shell syntax); serverName
|
|
71
|
-
(default cua_repl); override (default false);
|
|
72
|
-
|
|
73
|
-
(default
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
(default cua_repl); override (default false); safetyMode (default full, or
|
|
72
|
+
read-only to block mutating CUA methods and advertise read-only annotations);
|
|
73
|
+
appAccess (default allow all, with optional exact-name and ID allow/deny
|
|
74
|
+
rules); timeoutMs (default 30000); disabled (default false); maxTextChars
|
|
75
|
+
(default 200000); maxImageBytes (default 2097152);
|
|
76
|
+
allowGlobalPointerFallbacks (default false, off because it moves the real
|
|
77
|
+
pointer and changes focus; enable only for an explicit desktop automation
|
|
78
|
+
test where that input is intended). The proxy also reads matching
|
|
79
|
+
OPENCODE_COMPUTER_USE_* environment variables. Custom commands must implement
|
|
80
|
+
the native MCP operations used by cua: initialize, tools/list, tools/call,
|
|
81
|
+
and notifications.
|
|
79
82
|
|
|
80
83
|
6. Install
|
|
81
84
|
Published package:
|
|
@@ -110,13 +113,15 @@ desktop validation.
|
|
|
110
113
|
|
|
111
114
|
8. Safety
|
|
112
115
|
High-privilege desktop-control surface. The js tool is marked conservatively
|
|
113
|
-
as mutating
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
as mutating in full mode and read-only in read-only mode. The adapter also
|
|
117
|
+
bounds app bindings, sends native cancellation on timeouts, and sends a
|
|
118
|
+
best-effort turn-ended notification during shutdown. Keep OpenCode approval
|
|
119
|
+
prompts enabled for consequential actions (sending, deleting, purchasing,
|
|
120
|
+
uploading, changing permissions). Do not expose the session to an untrusted
|
|
121
|
+
model or run it unattended against password managers, banking, administrator,
|
|
122
|
+
or other high-value applications. Screenshots, accessibility trees, and typed
|
|
123
|
+
values can contain sensitive data and should stay local. Read SECURITY.md
|
|
124
|
+
before high-risk use. Manual read-only checks before acting:
|
|
120
125
|
open-computer-use doctor
|
|
121
126
|
open-computer-use list-apps
|
|
122
127
|
|
package/docs/llms.txt
CHANGED
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
- MCP server name: cua_repl. Model-facing tools: js (persistent evaluation) and js_reset (clean session).
|
|
25
25
|
- Async cua API: listApps, getApp, getState, getAXState, getScreenshot, getAXStateAndScreenshot, click, scroll, drag, typeText, pressKey, setValue, performSecondaryAction. Output helpers: nodeRepl.write, nodeRepl.emitImage.
|
|
26
26
|
- WebMCP preview: the docs page registers only read-only search_docs, get_install_config, and get_api_reference tools when document.modelContext is available. It is progressive enhancement, not stable browser support.
|
|
27
|
+
- Safety profiles: safetyMode=read-only blocks mutating CUA methods and changes js annotations; appAccess supports exact-name and ID allow/deny rules.
|
|
28
|
+
- Reliability: app bindings and output are bounded; native timeouts send cancellation; shutdown sends a best-effort turn-ended notification.
|
|
27
29
|
- No arbitrary shell escape. No run_shell in the model-facing API. Restricted VM blocks require, process, fetch, filesystem, dynamic import, and code generation.
|
|
28
30
|
- Published package: @frontendxlab/opencode-computer-use.
|
|
29
31
|
- Requirements: Node.js 18 or newer, OpenCode 1.18.29 or newer (V1) or OpenCode2 V2, plus a signed-in graphical desktop session with OS accessibility and capture permissions.
|
package/lib/json-line-peer.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
28
28
|
this.pending = new Map();
|
|
29
29
|
this.buffer = "";
|
|
30
30
|
this.stderr = "";
|
|
31
|
+
this.closeError = null;
|
|
31
32
|
this.closed = false;
|
|
32
33
|
this.exited = false;
|
|
33
34
|
this.terminationStarted = false;
|
|
@@ -93,6 +94,7 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
93
94
|
#close(error, terminate = false) {
|
|
94
95
|
if (!this.closed) {
|
|
95
96
|
this.closed = true;
|
|
97
|
+
this.closeError = error;
|
|
96
98
|
for (const pending of this.pending.values()) {
|
|
97
99
|
clearTimeout(pending.timer);
|
|
98
100
|
pending.reject(error);
|
|
@@ -100,7 +102,7 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
100
102
|
this.pending.clear();
|
|
101
103
|
this.emit("close", error);
|
|
102
104
|
}
|
|
103
|
-
if (terminate) void this.#terminateChild();
|
|
105
|
+
if (terminate) void this.#terminateChild(false);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
#killChild(force) {
|
|
@@ -135,26 +137,44 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
#terminateChild() {
|
|
140
|
+
#terminateChild(graceful = true) {
|
|
139
141
|
if (this.terminationStarted) return this.exitPromise;
|
|
140
142
|
this.terminationStarted = true;
|
|
141
143
|
try { this.child.stdin.end(); } catch {}
|
|
142
|
-
this.#killChild(false);
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
if (!graceful) this.#killChild(false);
|
|
145
|
+
const gracefulTimer = graceful ? setTimeout(() => this.#killChild(false), 100) : undefined;
|
|
146
|
+
const forceTimer = setTimeout(() => this.#killChild(true), 1_000);
|
|
147
|
+
gracefulTimer?.unref();
|
|
148
|
+
forceTimer.unref();
|
|
149
|
+
return this.exitPromise.finally(() => {
|
|
150
|
+
if (gracefulTimer) clearTimeout(gracefulTimer);
|
|
151
|
+
clearTimeout(forceTimer);
|
|
152
|
+
});
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
request(method, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
149
|
-
if (this.closed)
|
|
156
|
+
if (this.closed) {
|
|
157
|
+
const detail = this.closeError ? `: ${safeNativeErrorMessage(this.closeError)}` : "";
|
|
158
|
+
return Promise.reject(new Error(`native MCP is closed${detail}; restart the MCP server before trying again`));
|
|
159
|
+
}
|
|
150
160
|
const id = this.nextId++;
|
|
151
161
|
return new Promise((resolve, reject) => {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
const cancelTimedOutRequest = () => {
|
|
163
|
+
const key = String(id);
|
|
164
|
+
if (!this.pending.has(key)) return;
|
|
165
|
+
this.pending.delete(key);
|
|
166
|
+
try {
|
|
167
|
+
this.notify("notifications/cancelled", { requestId: id, reason: "timeout" });
|
|
168
|
+
} catch {
|
|
169
|
+
// The native process may already be closing; the timeout remains authoritative.
|
|
170
|
+
}
|
|
171
|
+
reject(new Error(`native MCP ${method} timed out after ${timeoutMs} ms; the native action may still be settling`));
|
|
172
|
+
};
|
|
173
|
+
const timer = timeoutMs > 0 ? setTimeout(cancelTimedOutRequest, timeoutMs) : undefined;
|
|
155
174
|
this.pending.set(String(id), { resolve, reject, timer });
|
|
156
175
|
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, (error) => {
|
|
157
176
|
if (!error) return;
|
|
177
|
+
if (!this.pending.has(String(id))) return;
|
|
158
178
|
if (timer) clearTimeout(timer);
|
|
159
179
|
this.pending.delete(String(id));
|
|
160
180
|
reject(error);
|
|
@@ -163,7 +183,33 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
163
183
|
}
|
|
164
184
|
|
|
165
185
|
notify(method, params = {}) {
|
|
166
|
-
if (
|
|
186
|
+
if (this.closed) return false;
|
|
187
|
+
try {
|
|
188
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
189
|
+
return true;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async notifyAsync(method, params = {}, timeoutMs = 250) {
|
|
196
|
+
if (this.closed) return false;
|
|
197
|
+
return new Promise((resolve) => {
|
|
198
|
+
let settled = false;
|
|
199
|
+
let timer;
|
|
200
|
+
const finish = (delivered) => {
|
|
201
|
+
if (settled) return;
|
|
202
|
+
settled = true;
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
resolve(delivered);
|
|
205
|
+
};
|
|
206
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
207
|
+
try {
|
|
208
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`, (error) => finish(!error));
|
|
209
|
+
} catch {
|
|
210
|
+
finish(false);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
167
213
|
}
|
|
168
214
|
|
|
169
215
|
async initialize() {
|
package/lib/options.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
const BACKENDS = new Set(["auto", "native", "custom"]);
|
|
2
|
+
const SAFETY_MODES = new Set(["full", "read-only"]);
|
|
2
3
|
const DEFAULT_SERVER_NAME = "cua_repl";
|
|
4
|
+
const MAX_APP_RULES = 256;
|
|
5
|
+
const MAX_APP_RULE_LENGTH = 256;
|
|
3
6
|
|
|
4
7
|
function isRecord(value) {
|
|
5
8
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -24,6 +27,36 @@ function commandOption(value) {
|
|
|
24
27
|
return [...value];
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
function appRuleList(value, name) {
|
|
31
|
+
if (value === undefined) return [];
|
|
32
|
+
if (!Array.isArray(value) || value.length > MAX_APP_RULES) {
|
|
33
|
+
throw new Error(`${name} must be an array with at most ${MAX_APP_RULES} entries`);
|
|
34
|
+
}
|
|
35
|
+
const rules = [];
|
|
36
|
+
for (const item of value) {
|
|
37
|
+
if (typeof item !== "string" || !item.trim() || item.length > MAX_APP_RULE_LENGTH) {
|
|
38
|
+
throw new Error(`${name} entries must be non-empty strings no longer than ${MAX_APP_RULE_LENGTH} characters`);
|
|
39
|
+
}
|
|
40
|
+
const rule = item.trim();
|
|
41
|
+
if (!rules.includes(rule)) rules.push(rule);
|
|
42
|
+
}
|
|
43
|
+
return rules;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function appAccessOption(value) {
|
|
47
|
+
if (value === undefined) return { default: "allow", allow: [], deny: [] };
|
|
48
|
+
if (!isRecord(value)) throw new Error("appAccess must be an object");
|
|
49
|
+
const defaultAccess = value.default === undefined ? "allow" : value.default;
|
|
50
|
+
if (typeof defaultAccess !== "string" || !["allow", "deny"].includes(defaultAccess.trim())) {
|
|
51
|
+
throw new Error("appAccess.default must be allow or deny");
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
default: defaultAccess.trim(),
|
|
55
|
+
allow: appRuleList(value.allow, "appAccess.allow"),
|
|
56
|
+
deny: appRuleList(value.deny, "appAccess.deny"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
27
60
|
function serverNameOption(value) {
|
|
28
61
|
const name = stringOption(value, DEFAULT_SERVER_NAME);
|
|
29
62
|
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
@@ -43,6 +76,10 @@ export function normalizeOptions(input = {}) {
|
|
|
43
76
|
if (backend === "custom" && !command) {
|
|
44
77
|
throw new Error("backend 'custom' requires a non-empty command array");
|
|
45
78
|
}
|
|
79
|
+
const safetyMode = stringOption(raw.safetyMode, "full");
|
|
80
|
+
if (!SAFETY_MODES.has(safetyMode)) {
|
|
81
|
+
throw new Error(`safetyMode must be one of: ${[...SAFETY_MODES].join(", ")}`);
|
|
82
|
+
}
|
|
46
83
|
|
|
47
84
|
return {
|
|
48
85
|
backend,
|
|
@@ -51,6 +88,8 @@ export function normalizeOptions(input = {}) {
|
|
|
51
88
|
allowGlobalPointerFallbacks: booleanOption(raw.allowGlobalPointerFallbacks, false),
|
|
52
89
|
override: booleanOption(raw.override, false),
|
|
53
90
|
serverName: serverNameOption(raw.serverName),
|
|
91
|
+
safetyMode,
|
|
92
|
+
appAccess: appAccessOption(raw.appAccess),
|
|
54
93
|
timeoutMs: boundedNumber(raw.timeoutMs, 30_000, 1_000, 300_000),
|
|
55
94
|
maxTextChars: boundedNumber(raw.maxTextChars, 200_000, 1_000, 2_000_000),
|
|
56
95
|
maxImageBytes: boundedNumber(raw.maxImageBytes, 2 * 1024 * 1024, 16_384, 16 * 1024 * 1024),
|
|
@@ -62,6 +101,8 @@ export function optionsToEnvironment(options) {
|
|
|
62
101
|
const environment = {
|
|
63
102
|
OPENCODE_COMPUTER_USE_BACKEND: normalized.backend,
|
|
64
103
|
OPENCODE_COMPUTER_USE_SERVER_NAME: normalized.serverName,
|
|
104
|
+
OPENCODE_COMPUTER_USE_SAFETY_MODE: normalized.safetyMode,
|
|
105
|
+
OPENCODE_COMPUTER_USE_APP_ACCESS: JSON.stringify(normalized.appAccess),
|
|
65
106
|
OPENCODE_COMPUTER_USE_TIMEOUT_MS: String(normalized.timeoutMs),
|
|
66
107
|
OPENCODE_COMPUTER_USE_MAX_TEXT_CHARS: String(normalized.maxTextChars),
|
|
67
108
|
OPENCODE_COMPUTER_USE_MAX_IMAGE_BYTES: String(normalized.maxImageBytes),
|
|
@@ -88,11 +129,22 @@ export function optionsFromEnvironment(environment = process.env) {
|
|
|
88
129
|
}
|
|
89
130
|
}
|
|
90
131
|
|
|
132
|
+
let appAccess;
|
|
133
|
+
if (environment.OPENCODE_COMPUTER_USE_APP_ACCESS) {
|
|
134
|
+
try {
|
|
135
|
+
appAccess = JSON.parse(environment.OPENCODE_COMPUTER_USE_APP_ACCESS);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error("OPENCODE_COMPUTER_USE_APP_ACCESS must be valid JSON");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
91
141
|
return normalizeOptions({
|
|
92
142
|
backend: environment.OPENCODE_COMPUTER_USE_BACKEND,
|
|
93
143
|
command,
|
|
94
144
|
allowGlobalPointerFallbacks: environment.OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS === "1",
|
|
95
145
|
serverName: environment.OPENCODE_COMPUTER_USE_SERVER_NAME,
|
|
146
|
+
safetyMode: environment.OPENCODE_COMPUTER_USE_SAFETY_MODE,
|
|
147
|
+
appAccess,
|
|
96
148
|
timeoutMs: Number(environment.OPENCODE_COMPUTER_USE_TIMEOUT_MS),
|
|
97
149
|
maxTextChars: Number(environment.OPENCODE_COMPUTER_USE_MAX_TEXT_CHARS),
|
|
98
150
|
maxImageBytes: Number(environment.OPENCODE_COMPUTER_USE_MAX_IMAGE_BYTES),
|
|
@@ -105,6 +157,8 @@ export const defaults = Object.freeze({
|
|
|
105
157
|
allowGlobalPointerFallbacks: false,
|
|
106
158
|
override: false,
|
|
107
159
|
serverName: DEFAULT_SERVER_NAME,
|
|
160
|
+
safetyMode: "full",
|
|
161
|
+
appAccess: { default: "allow", allow: [], deny: [] },
|
|
108
162
|
timeoutMs: 30_000,
|
|
109
163
|
maxTextChars: 200_000,
|
|
110
164
|
maxImageBytes: 2 * 1024 * 1024,
|
package/lib/repl-kernel.mjs
CHANGED
|
@@ -14,7 +14,12 @@ const native = {
|
|
|
14
14
|
});
|
|
15
15
|
},
|
|
16
16
|
};
|
|
17
|
-
const session = new PersistentJavaScriptSession({
|
|
17
|
+
const session = new PersistentJavaScriptSession({
|
|
18
|
+
native,
|
|
19
|
+
limits: workerData,
|
|
20
|
+
safetyMode: workerData.safetyMode,
|
|
21
|
+
appAccess: workerData.appAccess,
|
|
22
|
+
});
|
|
18
23
|
|
|
19
24
|
parentPort.on("message", async message => {
|
|
20
25
|
if (message.type === "native_response") {
|
package/lib/repl.mjs
CHANGED
|
@@ -30,8 +30,11 @@ const SERVER_INSTRUCTIONS = `UI automation through a persistent JavaScript REPL
|
|
|
30
30
|
|
|
31
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
32
|
|
|
33
|
-
function jsDescription(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
34
|
-
|
|
33
|
+
function jsDescription(timeoutMs = DEFAULT_TIMEOUT_MS, safetyMode = "full") {
|
|
34
|
+
const safetyNote = safetyMode === "read-only"
|
|
35
|
+
? " This session is read-only; mutating CUA methods are disabled."
|
|
36
|
+
: "";
|
|
37
|
+
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.${safetyNote}`;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
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.`;
|
|
@@ -217,9 +220,11 @@ export function createCuaApi(native, activeOutput, assertActive = () => {}) {
|
|
|
217
220
|
|
|
218
221
|
|
|
219
222
|
export class PersistentJavaScriptSession {
|
|
220
|
-
constructor({ native, limits = {} }) {
|
|
223
|
+
constructor({ native, limits = {}, safetyMode = "full", appAccess = {} }) {
|
|
221
224
|
this.native = native;
|
|
222
225
|
this.limits = outputLimits(limits);
|
|
226
|
+
this.safetyMode = safetyMode;
|
|
227
|
+
this.appAccess = appAccess;
|
|
223
228
|
this.active = null;
|
|
224
229
|
this.reset();
|
|
225
230
|
}
|
|
@@ -295,6 +300,7 @@ export class PersistentJavaScriptSession {
|
|
|
295
300
|
isActive,
|
|
296
301
|
schedule,
|
|
297
302
|
cancel,
|
|
303
|
+
{ safetyMode: this.safetyMode, appAccess: this.appAccess },
|
|
298
304
|
);
|
|
299
305
|
this.nodeRepl = this.context.nodeRepl;
|
|
300
306
|
this.cua = this.context.cua;
|
|
@@ -341,9 +347,12 @@ export class PersistentJavaScriptSession {
|
|
|
341
347
|
// loop can be terminated without hanging the MCP transport. Native tool calls
|
|
342
348
|
// are brokered back to the parent and still execute serially in the native MCP.
|
|
343
349
|
export class WorkerJavaScriptSession {
|
|
344
|
-
constructor({ native, limits = {} }) {
|
|
350
|
+
constructor({ native, limits = {}, safetyMode = "full", appAccess = {} }) {
|
|
345
351
|
this.native = native;
|
|
346
352
|
this.limits = outputLimits(limits);
|
|
353
|
+
this.safetyMode = safetyMode;
|
|
354
|
+
this.appAccess = appAccess;
|
|
355
|
+
this.workerData = { ...this.limits, safetyMode: this.safetyMode, appAccess: this.appAccess };
|
|
347
356
|
this.nextId = 1;
|
|
348
357
|
this.activeRequestId = null;
|
|
349
358
|
this.pending = new Map();
|
|
@@ -354,7 +363,7 @@ export class WorkerJavaScriptSession {
|
|
|
354
363
|
#start() {
|
|
355
364
|
const worker = new Worker(new URL("./repl-kernel.mjs", import.meta.url), {
|
|
356
365
|
execArgv: ["--experimental-repl-await"],
|
|
357
|
-
workerData: this.
|
|
366
|
+
workerData: this.workerData,
|
|
358
367
|
});
|
|
359
368
|
this.worker = worker;
|
|
360
369
|
worker.on("message", message => void this.#onMessage(worker, message));
|
|
@@ -491,11 +500,18 @@ export async function runServer({ command, args }) {
|
|
|
491
500
|
let shuttingDown;
|
|
492
501
|
let stopping = false;
|
|
493
502
|
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
494
|
-
const shutdown = () => {
|
|
503
|
+
const shutdown = (reason = "server_shutdown") => {
|
|
495
504
|
if (shuttingDown) return shuttingDown;
|
|
496
505
|
stopping = true;
|
|
497
506
|
const closeSession = session ? session.close() : Promise.resolve();
|
|
498
|
-
shuttingDown = closeSession.catch(() => {}).
|
|
507
|
+
shuttingDown = closeSession.catch(() => {}).then(async () => {
|
|
508
|
+
try {
|
|
509
|
+
await native.notifyAsync("notifications/turn-ended", { reason });
|
|
510
|
+
} catch {
|
|
511
|
+
// The native process may already be closing; local cleanup still continues.
|
|
512
|
+
}
|
|
513
|
+
return native.close().catch(() => {});
|
|
514
|
+
});
|
|
499
515
|
return shuttingDown;
|
|
500
516
|
};
|
|
501
517
|
const enqueue = (line) => {
|
|
@@ -505,7 +521,7 @@ export async function runServer({ command, args }) {
|
|
|
505
521
|
const rejectOversizedInput = () => {
|
|
506
522
|
buffer = "";
|
|
507
523
|
send({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Request frame exceeded the input size limit" } });
|
|
508
|
-
void shutdown();
|
|
524
|
+
void shutdown("input_limit");
|
|
509
525
|
};
|
|
510
526
|
const onInput = (chunk) => {
|
|
511
527
|
if (stopping) return;
|
|
@@ -527,15 +543,20 @@ export async function runServer({ command, args }) {
|
|
|
527
543
|
|
|
528
544
|
try {
|
|
529
545
|
await native.initialize();
|
|
530
|
-
session = new WorkerJavaScriptSession({
|
|
546
|
+
session = new WorkerJavaScriptSession({
|
|
547
|
+
native,
|
|
548
|
+
limits: options,
|
|
549
|
+
safetyMode: options.safetyMode,
|
|
550
|
+
appAccess: options.appAccess,
|
|
551
|
+
});
|
|
531
552
|
process.stdin.setEncoding("utf8");
|
|
532
553
|
process.stdin.on("data", onInput);
|
|
533
554
|
process.stdin.on("error", error => {
|
|
534
555
|
console.error(safeErrorMessage(error));
|
|
535
|
-
void shutdown();
|
|
556
|
+
void shutdown("stdin_error");
|
|
536
557
|
});
|
|
537
558
|
} catch (error) {
|
|
538
|
-
await shutdown();
|
|
559
|
+
await shutdown("startup_error");
|
|
539
560
|
throw error;
|
|
540
561
|
}
|
|
541
562
|
|
|
@@ -565,7 +586,7 @@ export async function runServer({ command, args }) {
|
|
|
565
586
|
});
|
|
566
587
|
}
|
|
567
588
|
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) } });
|
|
589
|
+
if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: toolDefinitions(options.timeoutMs, options) } });
|
|
569
590
|
if (method === "tools/call") {
|
|
570
591
|
if (!isJsonRecord(params) || typeof params.name !== "string") throw new Error("tools/call requires a tool name");
|
|
571
592
|
if (params.arguments !== undefined && !isJsonRecord(params.arguments)) throw new Error("tools/call arguments must be an object");
|
|
@@ -582,7 +603,12 @@ export async function runServer({ command, args }) {
|
|
|
582
603
|
if (typeof code !== "string" || code.length > MAX_CODE_CHARS) {
|
|
583
604
|
throw new Error(`js code must be a string no longer than ${MAX_CODE_CHARS} characters`);
|
|
584
605
|
}
|
|
606
|
+
const title = params.arguments?.title;
|
|
607
|
+
if (title !== undefined && (typeof title !== "string" || !title.trim() || title.length > 80)) {
|
|
608
|
+
throw new Error("js title must be a non-empty string no longer than 80 characters");
|
|
609
|
+
}
|
|
585
610
|
const result = boundToolResult(await session.run(code, timeoutMs), options);
|
|
611
|
+
if (title) result._meta = { ...(result._meta || {}), title: title.trim() };
|
|
586
612
|
return send({ jsonrpc: "2.0", id, result });
|
|
587
613
|
}
|
|
588
614
|
throw new Error(`Unsupported tool: ${params.name}`);
|
|
@@ -595,17 +621,20 @@ export async function runServer({ command, args }) {
|
|
|
595
621
|
|
|
596
622
|
const shutdownSignals = process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
597
623
|
for (const signal of shutdownSignals) {
|
|
598
|
-
process.on(signal, () => { void shutdown().finally(() => process.exit(0)); });
|
|
624
|
+
process.on(signal, () => { void shutdown("signal").finally(() => process.exit(0)); });
|
|
599
625
|
}
|
|
600
|
-
process.stdin.on("end", () => { void requestQueue.finally(shutdown); });
|
|
626
|
+
process.stdin.on("end", () => { void requestQueue.finally(() => shutdown("stdin_closed")); });
|
|
601
627
|
}
|
|
602
628
|
|
|
603
|
-
export function toolDefinitions(defaultTimeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
629
|
+
export function toolDefinitions(defaultTimeoutMs = DEFAULT_TIMEOUT_MS, { safetyMode = "full" } = {}) {
|
|
604
630
|
const requestedTimeout = Number.isFinite(defaultTimeoutMs) ? defaultTimeoutMs : DEFAULT_TIMEOUT_MS;
|
|
605
631
|
const timeoutMs = Math.min(Math.max(requestedTimeout, 1_000), MAX_TIMEOUT_MS);
|
|
606
|
-
const
|
|
632
|
+
const readOnly = safetyMode === "read-only";
|
|
633
|
+
const annotations = readOnly
|
|
634
|
+
? { destructiveHint: false, openWorldHint: false, readOnlyHint: true }
|
|
635
|
+
: { destructiveHint: true, openWorldHint: true, readOnlyHint: false };
|
|
607
636
|
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"] } },
|
|
637
|
+
{ name: "js", description: jsDescription(timeoutMs, safetyMode), 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
638
|
{ name: "js_reset", description: RESET_DESCRIPTION, annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true, idempotentHint: true }, inputSchema: { type: "object", additionalProperties: false, properties: {} } },
|
|
610
639
|
];
|
|
611
640
|
}
|
package/lib/sandbox.mjs
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
import { runInContext } from "node:vm";
|
|
2
2
|
|
|
3
3
|
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
const MAX_APP_BINDINGS = 128;
|
|
5
|
+
const MUTATING_APP_METHODS = new Set([
|
|
6
|
+
"click",
|
|
7
|
+
"drag",
|
|
8
|
+
"pressKey",
|
|
9
|
+
"scroll",
|
|
10
|
+
"setValue",
|
|
11
|
+
"typeText",
|
|
12
|
+
"performSecondaryAction",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function normalizedAppAccess(value) {
|
|
16
|
+
const policy = value && typeof value === "object" ? value : {};
|
|
17
|
+
const defaultAccess = policy.default === "deny" ? "deny" : "allow";
|
|
18
|
+
const normalizeRules = (rules) => Array.isArray(rules)
|
|
19
|
+
? rules.filter((rule) => typeof rule === "string" && rule.trim()).map((rule) => rule.trim().toLowerCase())
|
|
20
|
+
: [];
|
|
21
|
+
return {
|
|
22
|
+
default: defaultAccess,
|
|
23
|
+
allow: new Set(normalizeRules(policy.allow)),
|
|
24
|
+
deny: new Set(normalizeRules(policy.deny)),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function appMatches(app, rules) {
|
|
29
|
+
if (!app || typeof app !== "object") return false;
|
|
30
|
+
return [app.id, app.bundleId, app.name, app.displayName]
|
|
31
|
+
.filter((value) => typeof value === "string" && value.trim())
|
|
32
|
+
.some((value) => rules.has(value.trim().toLowerCase()));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function appNameMatches(app, name) {
|
|
36
|
+
if (!app || typeof app !== "object") return false;
|
|
37
|
+
const expected = String(name).trim().toLowerCase();
|
|
38
|
+
return [app.id, app.bundleId, app.name, app.displayName]
|
|
39
|
+
.filter((value) => typeof value === "string" && value.trim())
|
|
40
|
+
.some((value) => value.trim().toLowerCase() === expected);
|
|
41
|
+
}
|
|
4
42
|
|
|
5
43
|
const SANDBOX_FACTORY_SOURCE = String.raw`
|
|
6
44
|
((dispatch, write, emitImage, schedule, cancel) => {
|
|
@@ -237,22 +275,56 @@ function encodeSandboxResult(value) {
|
|
|
237
275
|
return payload;
|
|
238
276
|
}
|
|
239
277
|
|
|
240
|
-
export function createSandboxBridge(
|
|
278
|
+
export function createSandboxBridge(
|
|
279
|
+
api,
|
|
280
|
+
context,
|
|
281
|
+
activeOutput,
|
|
282
|
+
isActive,
|
|
283
|
+
schedule,
|
|
284
|
+
cancel,
|
|
285
|
+
{ safetyMode = "full", appAccess = {} } = {},
|
|
286
|
+
) {
|
|
241
287
|
const bindings = new Map();
|
|
288
|
+
const accessPolicy = normalizedAppAccess(appAccess);
|
|
242
289
|
let nextBinding = 1;
|
|
290
|
+
const isReadOnly = safetyMode === "read-only";
|
|
291
|
+
const appIsAllowed = (app) => {
|
|
292
|
+
if (appMatches(app, accessPolicy.deny)) return false;
|
|
293
|
+
if (accessPolicy.allow.size > 0) return appMatches(app, accessPolicy.allow);
|
|
294
|
+
return accessPolicy.default === "allow";
|
|
295
|
+
};
|
|
296
|
+
const assertNamedAppAllowed = async (name) => {
|
|
297
|
+
if (accessPolicy.allow.size === 0 && accessPolicy.deny.size === 0) return;
|
|
298
|
+
const apps = await api.listApps({ emit: false });
|
|
299
|
+
const matches = Array.isArray(apps) ? apps.filter((app) => appNameMatches(app, name)) : [];
|
|
300
|
+
if (matches.length > 0) {
|
|
301
|
+
if (matches.some((app) => !appIsAllowed(app))) {
|
|
302
|
+
throw new Error(`app access denied by policy: ${name}`);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!appIsAllowed({ id: name, displayName: name })) {
|
|
307
|
+
throw new Error(`app access denied by policy: ${name}`);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const filterApps = (apps) => Array.isArray(apps) ? apps.filter(appIsAllowed) : [];
|
|
243
311
|
const dispatch = async (method, args) => {
|
|
244
312
|
try {
|
|
245
313
|
if (!isActive()) throw new Error("CUA calls are only available while js is executing");
|
|
246
314
|
if (method === "getState") {
|
|
247
315
|
const state = await api.getState({ emit: false });
|
|
248
|
-
return encodeSandboxResult({ apps: state.apps });
|
|
316
|
+
return encodeSandboxResult({ apps: filterApps(state.apps) });
|
|
249
317
|
}
|
|
250
|
-
if (method === "listApps") return encodeSandboxResult(await api.listApps({ emit: false }));
|
|
318
|
+
if (method === "listApps") return encodeSandboxResult(filterApps(await api.listApps({ emit: false })));
|
|
251
319
|
if (method === "getApp") {
|
|
252
320
|
const app = args[0];
|
|
253
321
|
if (typeof app !== "string" || !app.trim()) {
|
|
254
322
|
throw new Error("cua.getApp expects a non-empty app name or bundle ID");
|
|
255
323
|
}
|
|
324
|
+
await assertNamedAppAllowed(app);
|
|
325
|
+
if (bindings.size >= MAX_APP_BINDINGS) {
|
|
326
|
+
throw new Error(`CUA app binding limit reached (${MAX_APP_BINDINGS}); call js_reset`);
|
|
327
|
+
}
|
|
256
328
|
const binding = await api.getApp(app);
|
|
257
329
|
const token = `app-${nextBinding++}`;
|
|
258
330
|
bindings.set(token, binding);
|
|
@@ -263,10 +335,14 @@ export function createSandboxBridge(api, context, activeOutput, isActive, schedu
|
|
|
263
335
|
return encodeSandboxResult(undefined);
|
|
264
336
|
}
|
|
265
337
|
if (method.startsWith("app:")) {
|
|
338
|
+
const methodName = method.slice(4);
|
|
339
|
+
if (isReadOnly && MUTATING_APP_METHODS.has(methodName)) {
|
|
340
|
+
throw new Error(`mutating CUA method disabled in read-only safety mode: ${methodName}`);
|
|
341
|
+
}
|
|
266
342
|
const [token, ...callArgs] = args;
|
|
267
343
|
const binding = bindings.get(token);
|
|
268
344
|
if (!binding) throw new Error("unknown CUA app binding");
|
|
269
|
-
const result = await binding[
|
|
345
|
+
const result = await binding[methodName](...callArgs);
|
|
270
346
|
return encodeSandboxResult(result);
|
|
271
347
|
}
|
|
272
348
|
throw new Error(`unknown sandbox method: ${method}`);
|