@frontendxlab/opencode-computer-use 0.1.0 → 0.1.2

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
@@ -75,7 +75,7 @@ capabilities remain OS and session dependent.
75
75
 
76
76
  - Node.js 18 or newer.
77
77
  - OpenCode 1.18.29 or newer for the hybrid V1 entrypoint.
78
- - OpenCode2 V2 for the `setup(ctx)` entrypoint.
78
+ - OpenCode2 V2 beta 19271 or newer for the `setup(ctx)` entrypoint.
79
79
  - A signed-in graphical desktop session.
80
80
  - macOS Accessibility and Screen Recording permissions.
81
81
  - Linux AT-SPI and the input/capture services required by the native runtime.
@@ -91,8 +91,40 @@ local MCP server named `cua_repl` by default.
91
91
 
92
92
  ```sh
93
93
  npm install @frontendxlab/opencode-computer-use
94
+ # or
95
+ pnpm install @frontendxlab/opencode-computer-use
94
96
  ```
95
97
 
98
+ The package `postinstall` step detects the installed OpenCode generation and
99
+ whether the install is local or global. It safely updates `opencode.json` or
100
+ `opencode.jsonc`, preserves comments and existing settings, and is idempotent.
101
+ When both generations are installed, it uses an existing `plugin` or `plugins`
102
+ configuration to disambiguate. If that is not possible, it makes no change and
103
+ prints both manual configuration formats.
104
+
105
+ You can run setup explicitly at any time:
106
+
107
+ ```sh
108
+ npx opencode-computer-use-setup
109
+ npx opencode-computer-use-setup --local --v2
110
+ npx opencode-computer-use-setup --global --v1
111
+ ```
112
+
113
+ Use `--dry-run` to inspect the action without changing files. Set
114
+ `OPENCODE_COMPUTER_USE_SKIP_SETUP=1` to disable the postinstall hook. Restart
115
+ OpenCode after a successful setup so the plugin is loaded.
116
+
117
+ Recent pnpm releases may require explicit approval before running lifecycle
118
+ scripts from dependencies. If pnpm reports `ERR_PNPM_IGNORED_BUILDS`, approve
119
+ this package once and rerun the install:
120
+
121
+ ```sh
122
+ pnpm approve-builds @frontendxlab/opencode-computer-use
123
+ pnpm install
124
+ ```
125
+
126
+ If scripts are intentionally disabled, run `pnpm exec opencode-computer-use-setup` manually instead.
127
+
96
128
  A local checkout can be loaded by replacing the package name with its absolute
97
129
  path.
98
130
 
@@ -141,12 +173,33 @@ automatic plugin discovery. That loads the session twice.
141
173
  | `command` | none | Required for `custom`; a native MCP executable followed by argv. |
142
174
  | `serverName` | `cua_repl` | MCP server name. |
143
175
  | `override` | `false` | Replace an existing MCP server with the same name. |
176
+ | `safetyMode` | `full` | `full` or `read-only`; read-only blocks click, drag, key, scroll, text, value, and secondary actions. |
177
+ | `appAccess` | allow all | Optional `default`, `allow`, and `deny` rules matched against app names and IDs. |
144
178
  | `timeoutMs` | `30000` | Default and maximum JavaScript evaluation timeout. |
145
179
  | `disabled` | `false` | Register the server disabled. |
146
180
  | `maxTextChars` | `200000` | Aggregate text budget for one tool result. |
147
181
  | `maxImageBytes` | `2097152` | Aggregate image payload budget for one tool result. |
148
182
  | `allowGlobalPointerFallbacks` | `false` | Explicitly allow native global pointer fallback for coordinate clicks and drags. |
149
183
 
184
+ For a deny-by-default desktop policy, configure the adapter explicitly:
185
+
186
+ ```json
187
+ {
188
+ "safetyMode": "read-only",
189
+ "appAccess": {
190
+ "default": "deny",
191
+ "allow": ["com.apple.TextEdit", "Text"],
192
+ "deny": ["com.apple.Safari"]
193
+ }
194
+ }
195
+ ```
196
+
197
+ App rules are exact, case-insensitive matches against app names and IDs. They
198
+ are defense in depth and do not replace macOS Accessibility and Screen Recording,
199
+ Linux AT-SPI, Windows UIA, or OpenCode approval settings. The read-only mode
200
+ also changes the MCP annotations for `js` so hosts can apply their normal
201
+ read-only approval policy.
202
+
150
203
  The proxy reads corresponding `OPENCODE_COMPUTER_USE_*` environment variables
151
204
  when launched directly.
152
205
 
@@ -242,6 +295,11 @@ the model-facing session. The native
242
295
  `run_shell` operation is not part of the Codex-style `cua` API. Do not add an
243
296
  arbitrary shell escape to the JavaScript session.
244
297
 
298
+ The adapter bounds persistent app bindings and output items, sends a native
299
+ cancellation notification when a request times out, and sends a best-effort
300
+ `notifications/turn-ended` message during shutdown. These are reliability and
301
+ cleanup safeguards, not a replacement for host approvals.
302
+
245
303
  ## Documentation WebMCP preview
246
304
 
247
305
  The Cloudflare Pages documentation includes a progressive, read-only WebMCP
package/SECURITY.md CHANGED
@@ -16,8 +16,17 @@ 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.
26
+ - The postinstall setup only adds this package to OpenCode's plugin list, keeps
27
+ JSONC comments, creates a `.bak` before replacement, and never edits a
28
+ conflicting or malformed configuration. Ambiguous setup is non-destructive
29
+ and prints manual instructions.
21
30
 
22
31
  ## Operator responsibilities
23
32
 
@@ -14,6 +14,10 @@ The persistent JavaScript session adapter in `lib/repl.mjs`,
14
14
  The native runtime is installed separately as the optional dependency
15
15
  `open-computer-use@0.3.5`, which is distributed under the same MIT license.
16
16
 
17
+ The automatic setup command uses `jsonc-parser@3.3.1`, copyright Microsoft
18
+ Corporation and contributors, under the MIT license. It is used only to make
19
+ targeted edits to OpenCode JSONC configuration files.
20
+
17
21
  The adapter was changed to resolve the optional runtime package, use a
18
22
  minimal desktop environment allowlist, expose the session through the
19
23
  OpenCode plugin entrypoint, and run model code in a restricted VM context.
@@ -0,0 +1,467 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { delimiter, dirname, join, resolve } from "node:path";
6
+ import { spawnSync } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import { applyEdits, modify, parse, parseTree } from "jsonc-parser";
9
+
10
+ const PACKAGE_NAME = "@frontendxlab/opencode-computer-use";
11
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12
+ const DEFAULT_V1_OPTIONS = { backend: "auto" };
13
+
14
+ export const FALLBACK_INSTRUCTIONS = `
15
+ Automatic OpenCode setup could not safely choose a configuration.
16
+
17
+ For OpenCode 1, add this to opencode.json or opencode.jsonc:
18
+
19
+ {
20
+ "$schema": "https://opencode.ai/config.json",
21
+ "plugin": [
22
+ ["@frontendxlab/opencode-computer-use", { "backend": "auto" }]
23
+ ]
24
+ }
25
+
26
+ For OpenCode2 V2, add this to opencode.json or opencode.jsonc:
27
+
28
+ {
29
+ "$schema": "https://opencode.ai/config.json",
30
+ "plugins": [
31
+ {
32
+ "package": "@frontendxlab/opencode-computer-use",
33
+ "options": { "backend": "auto" }
34
+ }
35
+ ]
36
+ }
37
+
38
+ For global OpenCode2 V2 setup, run:
39
+
40
+ opencode2 plugin add @frontendxlab/opencode-computer-use
41
+
42
+ After changing configuration, restart OpenCode:
43
+
44
+ opencode service restart
45
+ opencode2 service restart
46
+ `.trim();
47
+
48
+ function print(text, stream = "stdout") {
49
+ process[stream].write(`${text}\n`);
50
+ }
51
+
52
+ function parseArguments(argv) {
53
+ const result = {
54
+ scope: "auto",
55
+ flavor: "auto",
56
+ dryRun: false,
57
+ help: false,
58
+ };
59
+
60
+ for (let index = 0; index < argv.length; index += 1) {
61
+ const argument = argv[index];
62
+ if (argument === "--global" || argument === "-g") {
63
+ result.scope = "global";
64
+ } else if (argument === "--local" || argument === "--project") {
65
+ result.scope = "local";
66
+ } else if (argument === "--v1") {
67
+ result.flavor = "v1";
68
+ } else if (argument === "--v2") {
69
+ result.flavor = "v2";
70
+ } else if (argument === "--opencode-version") {
71
+ const value = argv[++index];
72
+ if (value !== "1" && value !== "2") {
73
+ throw new Error("--opencode-version must be 1 or 2");
74
+ }
75
+ result.flavor = value === "1" ? "v1" : "v2";
76
+ } else if (argument === "--dry-run") {
77
+ result.dryRun = true;
78
+ } else if (argument === "--help" || argument === "-h") {
79
+ result.help = true;
80
+ } else if (argument === "--auto") {
81
+ continue;
82
+ } else {
83
+ throw new Error(`Unknown setup option: ${argument}`);
84
+ }
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ function usage() {
91
+ return `Usage: opencode-computer-use-setup [options]
92
+
93
+ Options:
94
+ --auto Detect OpenCode generation and scope (default)
95
+ --local, --project Configure the current project
96
+ --global Configure the current user's global config
97
+ --v1 Configure the OpenCode 1 plugin format
98
+ --v2 Configure the OpenCode2 V2 plugin format
99
+ --opencode-version 1|2 Select the OpenCode generation explicitly
100
+ --dry-run Show the action without changing files
101
+ --help, -h Show this help
102
+ `;
103
+ }
104
+
105
+ function executableCandidates(command, env = process.env) {
106
+ const pathValue = env.PATH || "";
107
+ const directories = pathValue.split(delimiter).filter(Boolean);
108
+ if (process.platform === "win32") {
109
+ const extensions = (env.PATHEXT || ".EXE;.CMD;.BAT").split(";").filter(Boolean);
110
+ return directories.flatMap((directory) => [
111
+ join(directory, command),
112
+ ...extensions.map((extension) => join(directory, `${command}${extension.toLowerCase()}`)),
113
+ ...extensions.map((extension) => join(directory, `${command}${extension}`)),
114
+ ]);
115
+ }
116
+ return directories.map((directory) => join(directory, command));
117
+ }
118
+
119
+ function isExecutableFile(path) {
120
+ try {
121
+ return statSync(path).isFile();
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ function resolveCommand(command, env = process.env) {
128
+ return executableCandidates(command, env).find(isExecutableFile) || command;
129
+ }
130
+
131
+ function probeCommand(command, env = process.env) {
132
+ const executable = resolveCommand(command, env);
133
+ const result = spawnSync(executable, ["--version"], {
134
+ encoding: "utf8",
135
+ env,
136
+ shell: false,
137
+ windowsHide: true,
138
+ timeout: 10_000,
139
+ });
140
+ if (result.error || result.status !== 0) {
141
+ return {
142
+ command,
143
+ available: false,
144
+ version: null,
145
+ output: `${result.stdout || ""}${result.stderr || ""}`.trim(),
146
+ };
147
+ }
148
+ const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
149
+ const match = output.match(/(?:opencode2?|opencode)\s+v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/iu);
150
+ return {
151
+ command,
152
+ available: true,
153
+ version: match?.[1] || output.split(/\r?\n/u)[0] || "unknown",
154
+ output,
155
+ };
156
+ }
157
+
158
+ export function detectOpenCode(env = process.env) {
159
+ return {
160
+ v1: probeCommand("opencode", env),
161
+ v2: probeCommand("opencode2", env),
162
+ };
163
+ }
164
+
165
+ function packageKey(entry) {
166
+ if (typeof entry === "string") return entry;
167
+ if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
168
+ if (entry && typeof entry === "object" && typeof entry.package === "string") return entry.package;
169
+ return undefined;
170
+ }
171
+
172
+ function hasPackage(entries) {
173
+ return Array.isArray(entries) && entries.some((entry) => packageKey(entry) === PACKAGE_NAME);
174
+ }
175
+
176
+ function configFlavor(text) {
177
+ const root = parseTree(text);
178
+ if (!root || root.type !== "object") return null;
179
+ const names = (root.children || [])
180
+ .filter((child) => child.type === "property")
181
+ .map((property) => property.children?.[0]?.value)
182
+ .filter((name) => name === "plugin" || name === "plugins");
183
+ const hasV1 = names.includes("plugin");
184
+ const hasV2 = names.includes("plugins");
185
+ if (hasV1 && hasV2) return "ambiguous";
186
+ if (hasV1) return "v1";
187
+ if (hasV2) return "v2";
188
+ return null;
189
+ }
190
+
191
+ function existingConfigFiles(root, scope, env = process.env) {
192
+ const bases = scope === "global"
193
+ ? [globalConfigDir(env)]
194
+ : [root, join(root, ".opencode")];
195
+ const files = [];
196
+ for (const base of bases) {
197
+ for (const name of ["opencode.jsonc", "opencode.json"]) {
198
+ const file = join(base, name);
199
+ if (existsSync(file) && !files.includes(file)) files.push(file);
200
+ }
201
+ }
202
+ return files;
203
+ }
204
+
205
+ export function configFiles(root, scope, env = process.env) {
206
+ return existingConfigFiles(root, scope, env);
207
+ }
208
+
209
+ export function globalConfigDir(env = process.env) {
210
+ if (env.OPENCODE_CONFIG_DIR) return resolve(env.OPENCODE_CONFIG_DIR);
211
+ if (env.XDG_CONFIG_HOME) return join(resolve(env.XDG_CONFIG_HOME), "opencode");
212
+ const home = env.HOME || env.USERPROFILE || homedir();
213
+ return join(resolve(home), ".config", "opencode");
214
+ }
215
+
216
+ export function detectConfigFlavor(root, scope, env = process.env) {
217
+ const files = existingConfigFiles(root, scope, env);
218
+ let detected = null;
219
+ for (const file of files) {
220
+ let text;
221
+ try {
222
+ text = readFileSync(file, "utf8");
223
+ } catch {
224
+ continue;
225
+ }
226
+ const flavor = configFlavor(text);
227
+ if (flavor && detected && flavor !== detected) return "ambiguous";
228
+ if (flavor) detected = flavor;
229
+ }
230
+ return detected;
231
+ }
232
+
233
+ export function inferScope(requested, env = process.env) {
234
+ if (requested === "local" || requested === "global") return requested;
235
+ const globalFlag = env.npm_config_global ?? env.NPM_CONFIG_GLOBAL;
236
+ if (globalFlag === "true" || globalFlag === true || globalFlag === "1") return "global";
237
+ return "local";
238
+ }
239
+
240
+ function defaultConfigFile(root, scope, env) {
241
+ const configDir = scope === "global" ? globalConfigDir(env) : root;
242
+ return join(configDir, "opencode.json");
243
+ }
244
+
245
+ function addEntry(entries, flavor) {
246
+ if (hasPackage(entries)) return { entries, changed: false };
247
+ const entry = flavor === "v1"
248
+ ? [PACKAGE_NAME, { ...DEFAULT_V1_OPTIONS }]
249
+ : PACKAGE_NAME;
250
+ return { entries: [...entries, entry], changed: true };
251
+ }
252
+
253
+ function newConfig(flavor) {
254
+ const key = flavor === "v1" ? "plugin" : "plugins";
255
+ const entry = flavor === "v1"
256
+ ? [[PACKAGE_NAME, { ...DEFAULT_V1_OPTIONS }]]
257
+ : [PACKAGE_NAME];
258
+ return {
259
+ $schema: "https://opencode.ai/config.json",
260
+ [key]: entry,
261
+ };
262
+ }
263
+
264
+ export function updateConfigText(text, flavor) {
265
+ const parsed = parse(text);
266
+ if (text.trim() && (parsed === undefined || parsed === null || typeof parsed !== "object" || Array.isArray(parsed))) {
267
+ throw new Error("configuration is not a JSON or JSONC object");
268
+ }
269
+ const data = parsed && typeof parsed === "object" ? parsed : {};
270
+ if (data.plugin !== undefined && data.plugins !== undefined) {
271
+ throw new Error("configuration contains both plugin and plugins fields");
272
+ }
273
+ const key = flavor === "v1" ? "plugin" : "plugins";
274
+ const current = data[key];
275
+ if (current !== undefined && !Array.isArray(current)) {
276
+ throw new Error(`configuration field "${key}" is not an array`);
277
+ }
278
+ const result = addEntry(current || [], flavor);
279
+ if (!result.changed) return { text, changed: false };
280
+ const edits = modify(text, [key], result.entries, {
281
+ formattingOptions: {
282
+ insertSpaces: true,
283
+ tabSize: 2,
284
+ eol: text.includes("\r\n") ? "\r\n" : "\n",
285
+ },
286
+ });
287
+ return { text: applyEdits(text, edits), changed: true };
288
+ }
289
+
290
+ function writeConfig(file, content, dryRun) {
291
+ if (dryRun) return;
292
+ mkdirSync(dirname(file), { recursive: true });
293
+ if (existsSync(file)) {
294
+ copyFileSync(file, `${file}.bak`);
295
+ }
296
+ const temporary = `${file}.tmp-${process.pid}`;
297
+ writeFileSync(temporary, content, "utf8");
298
+ if (process.platform === "win32" && existsSync(file)) rmSync(file, { force: true });
299
+ renameSync(temporary, file);
300
+ }
301
+
302
+ function packageAlreadyConfigured(root, scope, env) {
303
+ for (const file of existingConfigFiles(root, scope, env)) {
304
+ try {
305
+ const data = parse(readFileSync(file, "utf8"));
306
+ const key = data?.plugins !== undefined ? "plugins" : data?.plugin !== undefined ? "plugin" : undefined;
307
+ if (key && hasPackage(data[key])) return file;
308
+ } catch {
309
+ continue;
310
+ }
311
+ }
312
+ return undefined;
313
+ }
314
+
315
+ function runPluginCli(flavor, scope, root, env) {
316
+ const command = flavor === "v1" ? "opencode" : "opencode2";
317
+ const args = ["plugin", "add", PACKAGE_NAME];
318
+ if (flavor === "v1" && scope === "global") args.push("--global");
319
+ const result = spawnSync(resolveCommand(command, env), args, {
320
+ cwd: root,
321
+ encoding: "utf8",
322
+ env,
323
+ shell: process.platform === "win32",
324
+ windowsHide: true,
325
+ timeout: 120_000,
326
+ });
327
+ if (result.error || result.status !== 0) {
328
+ const detail = `${result.stdout || ""}${result.stderr || ""}`.trim().slice(0, 500);
329
+ throw new Error(`${command} plugin add failed${detail ? `: ${detail}` : ""}`);
330
+ }
331
+ }
332
+
333
+ function configForSetup(root, scope, flavor, env, dryRun) {
334
+ const files = existingConfigFiles(root, scope, env);
335
+ if (files.length > 0) {
336
+ const flavors = files.map((file) => {
337
+ try {
338
+ return configFlavor(readFileSync(file, "utf8"));
339
+ } catch {
340
+ return null;
341
+ }
342
+ });
343
+ if (flavors.includes("ambiguous")) {
344
+ throw new Error("configuration contains both plugin and plugins fields");
345
+ }
346
+ if (new Set(flavors.filter(Boolean)).size > 1) {
347
+ throw new Error("multiple conflicting OpenCode configuration files were found");
348
+ }
349
+ }
350
+ const file = files[0] || defaultConfigFile(root, scope, env);
351
+ if (!existsSync(file)) {
352
+ const content = JSON.stringify(newConfig(flavor), null, 2) + "\n";
353
+ writeConfig(file, content, dryRun);
354
+ return { file, changed: true };
355
+ }
356
+ const result = updateConfigText(readFileSync(file, "utf8"), flavor);
357
+ writeConfig(file, result.text, dryRun);
358
+ return { file, changed: result.changed };
359
+ }
360
+
361
+ function chooseFlavor(requested, detection, config) {
362
+ if (config === "ambiguous") {
363
+ throw new Error("configuration contains both plugin and plugins fields");
364
+ }
365
+ if (requested !== "auto") {
366
+ if (config && config !== requested) {
367
+ throw new Error(`selected ${requested} conflicts with the existing ${config} configuration`);
368
+ }
369
+ return requested;
370
+ }
371
+ if (detection.v1.available && detection.v2.available) {
372
+ if (config === "v1" || config === "v2") return config;
373
+ throw new Error("both OpenCode 1 and OpenCode2 are installed, and the configuration is ambiguous");
374
+ }
375
+ if (detection.v1.available) return "v1";
376
+ if (detection.v2.available) return "v2";
377
+ throw new Error("neither opencode nor opencode2 was found");
378
+ }
379
+
380
+ function chooseRoot(scope, env) {
381
+ if (scope === "global") return process.cwd();
382
+ const initial = env.INIT_CWD || process.cwd();
383
+ return resolve(initial);
384
+ }
385
+
386
+ export async function setup(options = {}, env = process.env, io = print) {
387
+ const requestedScope = options.scope || "auto";
388
+ const requestedFlavor = options.flavor || "auto";
389
+ const scope = inferScope(requestedScope, env);
390
+ const root = chooseRoot(scope, env);
391
+ const detection = detectOpenCode(env);
392
+ const config = detectConfigFlavor(root, scope, env);
393
+ const flavor = chooseFlavor(requestedFlavor, detection, config);
394
+ const dryRun = Boolean(options.dryRun);
395
+ let result;
396
+ if (scope === "global" && !dryRun) {
397
+ const existing = packageAlreadyConfigured(root, scope, env);
398
+ if (existing) {
399
+ result = { file: existing, changed: false };
400
+ } else {
401
+ runPluginCli(flavor, scope, root, env);
402
+ const configured = packageAlreadyConfigured(root, scope, env);
403
+ if (!configured) throw new Error(`${flavor.toUpperCase()} plugin command did not register the package`);
404
+ result = { file: configured, changed: true };
405
+ }
406
+ } else {
407
+ result = configForSetup(root, scope, flavor, env, dryRun);
408
+ }
409
+ io(
410
+ `OpenCode Computer Use setup ${result.changed ? "configured" : "already configured"} for ${flavor.toUpperCase()} (${scope}).`,
411
+ );
412
+ if (result.changed) io(`Configuration: ${result.file}`);
413
+ return { ...result, scope, flavor, detection };
414
+ }
415
+
416
+ export function printHelp() {
417
+ print(usage());
418
+ }
419
+
420
+ function isDevelopmentInstall(env) {
421
+ const initial = env.INIT_CWD ? resolve(env.INIT_CWD) : undefined;
422
+ return env.npm_lifecycle_event === "postinstall" && initial === PACKAGE_ROOT;
423
+ }
424
+
425
+ export async function main(argv = process.argv.slice(2), env = process.env, io = print) {
426
+ if (isDevelopmentInstall(env)) {
427
+ io("OpenCode Computer Use setup skipped for a package-development install.");
428
+ return 0;
429
+ }
430
+ if (env.OPENCODE_COMPUTER_USE_SKIP_SETUP === "1") {
431
+ io("OpenCode Computer Use setup skipped by OPENCODE_COMPUTER_USE_SKIP_SETUP.");
432
+ return 0;
433
+ }
434
+ let options;
435
+ try {
436
+ options = parseArguments(argv);
437
+ } catch (error) {
438
+ io(`[opencode-computer-use] ${error instanceof Error ? error.message : String(error)}`, "stderr");
439
+ io(usage(), "stderr");
440
+ return 2;
441
+ }
442
+ if (options.help) {
443
+ printHelp();
444
+ return 0;
445
+ }
446
+ return setup(options, env, io)
447
+ .then(() => 0)
448
+ .catch((error) => {
449
+ io(`[opencode-computer-use] Automatic setup was not changed: ${error instanceof Error ? error.message : String(error)}`, "stderr");
450
+ io(FALLBACK_INSTRUCTIONS, "stderr");
451
+ return 0;
452
+ });
453
+ }
454
+
455
+ function sameFile(left, right) {
456
+ try {
457
+ return realpathSync(left) === realpathSync(right);
458
+ } catch {
459
+ return resolve(left) === resolve(right);
460
+ }
461
+ }
462
+
463
+ const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
464
+ const scriptPath = fileURLToPath(import.meta.url);
465
+ if (invokedPath && sameFile(invokedPath, scriptPath)) {
466
+ process.exitCode = await main();
467
+ }
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,
@@ -456,7 +456,10 @@
456
456
  cd opencode-computer-use
457
457
  npm install</code></pre><button class="copy" type="button" data-copy="#clone">Copy</button></div>
458
458
  <p>For a published-package install, use:</p>
459
- <div class="code"><pre><code id="npm-install">npm install @frontendxlab/opencode-computer-use</code></pre><button class="copy" type="button" data-copy="#npm-install">Copy</button></div>
459
+ <div class="code"><pre><code id="npm-install">npm install @frontendxlab/opencode-computer-use
460
+ # pnpm install @frontendxlab/opencode-computer-use also works</code></pre><button class="copy" type="button" data-copy="#npm-install">Copy</button></div>
461
+ <p>The package postinstall step detects OpenCode 1 versus OpenCode2 and local versus global installation. It edits JSONC without discarding comments, avoids duplicate entries, and prints both manual formats when the choice is ambiguous. Run it explicitly with <code>npx opencode-computer-use-setup</code>, or add <code>--local</code>, <code>--global</code>, <code>--v1</code>, or <code>--v2</code> when you want to choose explicitly.</p>
462
+ <p>Recent pnpm releases can require explicit lifecycle-script approval. If pnpm reports <code>ERR_PNPM_IGNORED_BUILDS</code>, run <code>pnpm approve-builds @frontendxlab/opencode-computer-use</code> once, then rerun <code>pnpm install</code>. If scripts are disabled, run <code>pnpm exec opencode-computer-use-setup</code> manually.</p>
460
463
  <div class="tabs" role="tablist" aria-label="OpenCode generation">
461
464
  <button role="tab" id="tab-v1" aria-controls="panel-v1" aria-selected="true">OpenCode 1</button>
462
465
  <button role="tab" id="tab-v2" aria-controls="panel-v2" aria-selected="false" tabindex="-1">OpenCode2</button>
@@ -548,6 +551,12 @@ await chrome.getAXState({ textLimit: 12000 });</code></pre><button class="copy"
548
551
  <div class="code"><pre><code id="options-json">{
549
552
  "backend": "auto",
550
553
  "serverName": "cua_repl",
554
+ "safetyMode": "full",
555
+ "appAccess": {
556
+ "default": "allow",
557
+ "allow": [],
558
+ "deny": []
559
+ },
551
560
  "timeoutMs": 30000,
552
561
  "maxTextChars": 200000,
553
562
  "maxImageBytes": 2097152,
@@ -556,6 +565,8 @@ await chrome.getAXState({ textLimit: 12000 });</code></pre><button class="copy"
556
565
  <ul class="cards">
557
566
  <li><strong>backend</strong><span><code>auto</code>, <code>native</code>, or a fixed <code>custom</code> command.</span></li>
558
567
  <li><strong>serverName</strong><span>Rename the generated MCP server when a project needs isolation.</span></li>
568
+ <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>
569
+ <li><strong>appAccess</strong><span>Optional exact-name and ID allow or deny rules, evaluated before native access.</span></li>
559
570
  <li><strong>timeoutMs</strong><span>Bounds JavaScript evaluation. A timeout resets the worker session.</span></li>
560
571
  <li><strong>maxTextChars</strong><span>Aggregate text budget for one tool result.</span></li>
561
572
  <li><strong>maxImageBytes</strong><span>Aggregate image budget for one tool result.</span></li>
@@ -601,7 +612,10 @@ nodeRepl.write(state);</code></pre><button class="copy" type="button" data-copy=
601
612
  <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
613
  <li><strong>Restricted VM</strong><span>Filesystem, process, fetch, dynamic import, code generation, and host built-ins are blocked.</span></li>
603
614
  <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>
615
+ <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>
616
+ <li><strong>App policy</strong><span>Exact-name and ID allow or deny rules provide adapter-level defense in depth.</span></li>
617
+ <li><strong>Bounded transport</strong><span>Input frames, output text, images, content items, app bindings, and native responses are bounded.</span></li>
618
+ <li><strong>Lifecycle cleanup</strong><span>Timeouts send native cancellation, and shutdown sends a best-effort turn-ended notification.</span></li>
605
619
  <li><strong>Approval preserving</strong><span>Consequential actions still pass through OpenCode and OS permission boundaries.</span></li>
606
620
  <li><strong>Safe defaults</strong><span>Global pointer fallback is disabled until explicitly enabled.</span></li>
607
621
  </ul>
@@ -635,7 +649,7 @@ nodeRepl.write(state);</code></pre><button class="copy" type="button" data-copy=
635
649
  <strong>Surface map</strong>
636
650
  <p>Plugin: <code>opencode-computer-use</code><br>MCP: <code>cua_repl</code><br>Tools: <code>js</code>, <code>js_reset</code></p>
637
651
  <strong>Default policy</strong>
638
- <p>Local stdio only. Global pointer fallback off. Native OS permissions enforced.</p>
652
+ <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
653
  <strong>Updated</strong>
640
654
  <p>Published September 25, 2026. Updated September 25, 2026.</p>
641
655
  <strong>WebMCP</strong>
@@ -68,18 +68,29 @@ 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:
82
85
  npm install @frontendxlab/opencode-computer-use
86
+ The package postinstall step detects OpenCode 1 versus OpenCode2 and local
87
+ versus global installation, edits JSONC without dropping comments, and is
88
+ idempotent. If both generations are ambiguous it makes no change and prints
89
+ manual instructions. Run it explicitly with npx opencode-computer-use-setup;
90
+ use --local, --global, --v1, or --v2 to choose explicitly. If pnpm reports
91
+ ERR_PNPM_IGNORED_BUILDS, approve the package once with pnpm approve-builds
92
+ @frontendxlab/opencode-computer-use, or run pnpm exec
93
+ opencode-computer-use-setup manually.
83
94
  OpenCode 1 (opencode.json or opencode.jsonc, plugin list):
84
95
  { "$schema": "https://opencode.ai/config.json",
85
96
  "plugin": [["@frontendxlab/opencode-computer-use", { "backend": "auto" }]] }
@@ -110,19 +121,24 @@ desktop validation.
110
121
 
111
122
  8. Safety
112
123
  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:
124
+ as mutating in full mode and read-only in read-only mode. The adapter also
125
+ bounds app bindings, sends native cancellation on timeouts, and sends a
126
+ best-effort turn-ended notification during shutdown. Keep OpenCode approval
127
+ prompts enabled for consequential actions (sending, deleting, purchasing,
128
+ uploading, changing permissions). Do not expose the session to an untrusted
129
+ model or run it unattended against password managers, banking, administrator,
130
+ or other high-value applications. Screenshots, accessibility trees, and typed
131
+ values can contain sensitive data and should stay local. Read SECURITY.md
132
+ before high-risk use. Manual read-only checks before acting:
120
133
  open-computer-use doctor
121
134
  open-computer-use list-apps
122
135
 
123
136
  9. Troubleshooting (from the docs page)
124
- - cua_repl not listed: check the active generation config, restart the TUI,
125
- run opencode mcp list, avoid double registration.
137
+ - cua_repl not listed: check the active generation config, restart the TUI or
138
+ background service after installation, run opencode mcp list, and avoid
139
+ double registration. For global OpenCode2, run opencode2 plugin add
140
+ @frontendxlab/opencode-computer-use if the package was installed before
141
+ automatic setup was available.
126
142
  - Node not found: set OPENCODE_NODE or fix server PATH.
127
143
  - Empty app list: run open-computer-use list-apps in the same graphical
128
144
  session; confirm the app runs and desktop accessibility is available.
package/docs/llms.txt CHANGED
@@ -24,7 +24,9 @@
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
- - Published package: @frontendxlab/opencode-computer-use.
30
+ - Published package: @frontendxlab/opencode-computer-use. Its postinstall setup detects OpenCode generation and local/global scope, preserves JSONC, and prints manual instructions when ambiguous. Explicit setup: npx opencode-computer-use-setup --local|--global --v1|--v2.
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.
30
32
  - Native runtimes packaged for darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-arm64, win32-x64. Live smoke tests passed on Linux x64 only; macOS and Windows need on-host validation.
@@ -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.2",
4
4
  "description": "A dual OpenCode and OpenCode2 plugin for local cross-platform desktop control.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,7 +28,8 @@
28
28
  "./server": "./index.js"
29
29
  },
30
30
  "bin": {
31
- "opencode-computer-use-repl": "bin/opencode-computer-use-repl.js"
31
+ "opencode-computer-use-repl": "bin/opencode-computer-use-repl.js",
32
+ "opencode-computer-use-setup": "bin/opencode-computer-use-setup.js"
32
33
  },
33
34
  "files": [
34
35
  "bin/",
@@ -42,16 +43,20 @@
42
43
  ],
43
44
  "engines": {
44
45
  "node": ">=18",
45
- "opencode": ">=1.18.29"
46
+ "opencode": ">=0.0.0-beta-18684"
46
47
  },
47
48
  "scripts": {
48
- "lint": "node --check index.js && node --check bin/opencode-computer-use-repl.js && node --check lib/backend.js && node --check lib/json-line-peer.mjs && node --check lib/options.js && node --check lib/output.mjs && node --check lib/repl.mjs && node --check lib/repl-kernel.mjs && node --check lib/sandbox.mjs",
49
+ "postinstall": "node bin/opencode-computer-use-setup.js --auto",
50
+ "lint": "node --check index.js && node --check bin/opencode-computer-use-repl.js && node --check bin/opencode-computer-use-setup.js && node --check lib/backend.js && node --check lib/json-line-peer.mjs && node --check lib/options.js && node --check lib/output.mjs && node --check lib/repl.mjs && node --check lib/repl-kernel.mjs && node --check lib/sandbox.mjs",
49
51
  "test": "node --test",
50
- "prepack": "node --check index.js && node --check bin/opencode-computer-use-repl.js && node -e \"JSON.parse(require('fs').readFileSync('package.json','utf8'))\"",
52
+ "prepack": "node --check index.js && node --check bin/opencode-computer-use-repl.js && node --check bin/opencode-computer-use-setup.js && node -e \"JSON.parse(require('fs').readFileSync('package.json','utf8'))\"",
51
53
  "prepublishOnly": "npm run lint && npm test"
52
54
  },
53
55
  "optionalDependencies": {
54
56
  "open-computer-use": "0.3.5"
55
57
  },
56
- "sideEffects": false
58
+ "sideEffects": false,
59
+ "dependencies": {
60
+ "jsonc-parser": "^3.3.1"
61
+ }
57
62
  }