@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 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.0",
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>Bounded transport</strong><span>Input frames, output text, images, content items, and native responses are bounded.</span></li>
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>
@@ -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); timeoutMs (default 30000);
72
- disabled (default false); maxTextChars (default 200000); maxImageBytes
73
- (default 2097152); allowGlobalPointerFallbacks (default false, off because
74
- it moves the real pointer and changes focus; enable only for an explicit
75
- desktop automation test where that input is intended). The proxy also reads
76
- matching OPENCODE_COMPUTER_USE_* environment variables. Custom commands
77
- must implement the native MCP operations used by cua: initialize,
78
- tools/list, tools/call, and notifications.
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. Keep OpenCode approval prompts enabled for consequential
114
- actions (sending, deleting, purchasing, uploading, changing permissions).
115
- Do not expose the session to an untrusted model or run it unattended
116
- against password managers, banking, administrator, or other high-value
117
- applications. Screenshots, accessibility trees, and typed values can contain
118
- sensitive data and should stay local. Read SECURITY.md before high-risk
119
- use. Manual read-only checks before acting:
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.
@@ -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 timer = setTimeout(() => this.#killChild(true), 1_000);
144
- timer.unref();
145
- return this.exitPromise.finally(() => clearTimeout(timer));
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) return Promise.reject(new Error("native MCP is 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 timer = timeoutMs > 0
153
- ? setTimeout(() => { this.pending.delete(String(id)); reject(new Error(`native MCP ${method} timed out after ${timeoutMs} ms`)); }, timeoutMs)
154
- : undefined;
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 (!this.closed) this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
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,
@@ -14,7 +14,12 @@ const native = {
14
14
  });
15
15
  },
16
16
  };
17
- const session = new PersistentJavaScriptSession({ native, limits: workerData });
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
- 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.`;
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.limits,
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(() => {}).finally(() => native.close());
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({ native, limits: options });
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 annotations = { destructiveHint: true, openWorldHint: true, readOnlyHint: false };
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(api, context, activeOutput, isActive, schedule, cancel) {
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[method.slice(4)](...callArgs);
345
+ const result = await binding[methodName](...callArgs);
270
346
  return encodeSandboxResult(result);
271
347
  }
272
348
  throw new Error(`unknown sandbox method: ${method}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontendxlab/opencode-computer-use",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "A dual OpenCode and OpenCode2 plugin for local cross-platform desktop control.",
5
5
  "license": "MIT",
6
6
  "repository": {