@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 +59 -1
- package/SECURITY.md +9 -0
- package/THIRD_PARTY_NOTICES.md +4 -0
- package/bin/opencode-computer-use-setup.js +467 -0
- package/docs/index.html +18 -4
- package/docs/llms-full.txt +33 -17
- package/docs/llms.txt +3 -1
- package/lib/json-line-peer.mjs +57 -11
- package/lib/options.js +54 -0
- package/lib/repl-kernel.mjs +6 -1
- package/lib/repl.mjs +46 -17
- package/lib/sandbox.mjs +80 -4
- package/package.json +11 -6
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
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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.
|
|
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
|
|
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>
|
|
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>
|
package/docs/llms-full.txt
CHANGED
|
@@ -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);
|
|
72
|
-
|
|
73
|
-
(default
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
(default cua_repl); override (default false); safetyMode (default full, or
|
|
72
|
+
read-only to block mutating CUA methods and advertise read-only annotations);
|
|
73
|
+
appAccess (default allow all, with optional exact-name and ID allow/deny
|
|
74
|
+
rules); timeoutMs (default 30000); disabled (default false); maxTextChars
|
|
75
|
+
(default 200000); maxImageBytes (default 2097152);
|
|
76
|
+
allowGlobalPointerFallbacks (default false, off because it moves the real
|
|
77
|
+
pointer and changes focus; enable only for an explicit desktop automation
|
|
78
|
+
test where that input is intended). The proxy also reads matching
|
|
79
|
+
OPENCODE_COMPUTER_USE_* environment variables. Custom commands must implement
|
|
80
|
+
the native MCP operations used by cua: initialize, tools/list, tools/call,
|
|
81
|
+
and notifications.
|
|
79
82
|
|
|
80
83
|
6. Install
|
|
81
84
|
Published package:
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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.
|
package/lib/json-line-peer.mjs
CHANGED
|
@@ -28,6 +28,7 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
28
28
|
this.pending = new Map();
|
|
29
29
|
this.buffer = "";
|
|
30
30
|
this.stderr = "";
|
|
31
|
+
this.closeError = null;
|
|
31
32
|
this.closed = false;
|
|
32
33
|
this.exited = false;
|
|
33
34
|
this.terminationStarted = false;
|
|
@@ -93,6 +94,7 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
93
94
|
#close(error, terminate = false) {
|
|
94
95
|
if (!this.closed) {
|
|
95
96
|
this.closed = true;
|
|
97
|
+
this.closeError = error;
|
|
96
98
|
for (const pending of this.pending.values()) {
|
|
97
99
|
clearTimeout(pending.timer);
|
|
98
100
|
pending.reject(error);
|
|
@@ -100,7 +102,7 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
100
102
|
this.pending.clear();
|
|
101
103
|
this.emit("close", error);
|
|
102
104
|
}
|
|
103
|
-
if (terminate) void this.#terminateChild();
|
|
105
|
+
if (terminate) void this.#terminateChild(false);
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
#killChild(force) {
|
|
@@ -135,26 +137,44 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
#terminateChild() {
|
|
140
|
+
#terminateChild(graceful = true) {
|
|
139
141
|
if (this.terminationStarted) return this.exitPromise;
|
|
140
142
|
this.terminationStarted = true;
|
|
141
143
|
try { this.child.stdin.end(); } catch {}
|
|
142
|
-
this.#killChild(false);
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
if (!graceful) this.#killChild(false);
|
|
145
|
+
const gracefulTimer = graceful ? setTimeout(() => this.#killChild(false), 100) : undefined;
|
|
146
|
+
const forceTimer = setTimeout(() => this.#killChild(true), 1_000);
|
|
147
|
+
gracefulTimer?.unref();
|
|
148
|
+
forceTimer.unref();
|
|
149
|
+
return this.exitPromise.finally(() => {
|
|
150
|
+
if (gracefulTimer) clearTimeout(gracefulTimer);
|
|
151
|
+
clearTimeout(forceTimer);
|
|
152
|
+
});
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
request(method, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
149
|
-
if (this.closed)
|
|
156
|
+
if (this.closed) {
|
|
157
|
+
const detail = this.closeError ? `: ${safeNativeErrorMessage(this.closeError)}` : "";
|
|
158
|
+
return Promise.reject(new Error(`native MCP is closed${detail}; restart the MCP server before trying again`));
|
|
159
|
+
}
|
|
150
160
|
const id = this.nextId++;
|
|
151
161
|
return new Promise((resolve, reject) => {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
const cancelTimedOutRequest = () => {
|
|
163
|
+
const key = String(id);
|
|
164
|
+
if (!this.pending.has(key)) return;
|
|
165
|
+
this.pending.delete(key);
|
|
166
|
+
try {
|
|
167
|
+
this.notify("notifications/cancelled", { requestId: id, reason: "timeout" });
|
|
168
|
+
} catch {
|
|
169
|
+
// The native process may already be closing; the timeout remains authoritative.
|
|
170
|
+
}
|
|
171
|
+
reject(new Error(`native MCP ${method} timed out after ${timeoutMs} ms; the native action may still be settling`));
|
|
172
|
+
};
|
|
173
|
+
const timer = timeoutMs > 0 ? setTimeout(cancelTimedOutRequest, timeoutMs) : undefined;
|
|
155
174
|
this.pending.set(String(id), { resolve, reject, timer });
|
|
156
175
|
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`, (error) => {
|
|
157
176
|
if (!error) return;
|
|
177
|
+
if (!this.pending.has(String(id))) return;
|
|
158
178
|
if (timer) clearTimeout(timer);
|
|
159
179
|
this.pending.delete(String(id));
|
|
160
180
|
reject(error);
|
|
@@ -163,7 +183,33 @@ export class JsonLinePeer extends EventEmitter {
|
|
|
163
183
|
}
|
|
164
184
|
|
|
165
185
|
notify(method, params = {}) {
|
|
166
|
-
if (
|
|
186
|
+
if (this.closed) return false;
|
|
187
|
+
try {
|
|
188
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
|
189
|
+
return true;
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async notifyAsync(method, params = {}, timeoutMs = 250) {
|
|
196
|
+
if (this.closed) return false;
|
|
197
|
+
return new Promise((resolve) => {
|
|
198
|
+
let settled = false;
|
|
199
|
+
let timer;
|
|
200
|
+
const finish = (delivered) => {
|
|
201
|
+
if (settled) return;
|
|
202
|
+
settled = true;
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
resolve(delivered);
|
|
205
|
+
};
|
|
206
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
207
|
+
try {
|
|
208
|
+
this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`, (error) => finish(!error));
|
|
209
|
+
} catch {
|
|
210
|
+
finish(false);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
167
213
|
}
|
|
168
214
|
|
|
169
215
|
async initialize() {
|
package/lib/options.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
const BACKENDS = new Set(["auto", "native", "custom"]);
|
|
2
|
+
const SAFETY_MODES = new Set(["full", "read-only"]);
|
|
2
3
|
const DEFAULT_SERVER_NAME = "cua_repl";
|
|
4
|
+
const MAX_APP_RULES = 256;
|
|
5
|
+
const MAX_APP_RULE_LENGTH = 256;
|
|
3
6
|
|
|
4
7
|
function isRecord(value) {
|
|
5
8
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -24,6 +27,36 @@ function commandOption(value) {
|
|
|
24
27
|
return [...value];
|
|
25
28
|
}
|
|
26
29
|
|
|
30
|
+
function appRuleList(value, name) {
|
|
31
|
+
if (value === undefined) return [];
|
|
32
|
+
if (!Array.isArray(value) || value.length > MAX_APP_RULES) {
|
|
33
|
+
throw new Error(`${name} must be an array with at most ${MAX_APP_RULES} entries`);
|
|
34
|
+
}
|
|
35
|
+
const rules = [];
|
|
36
|
+
for (const item of value) {
|
|
37
|
+
if (typeof item !== "string" || !item.trim() || item.length > MAX_APP_RULE_LENGTH) {
|
|
38
|
+
throw new Error(`${name} entries must be non-empty strings no longer than ${MAX_APP_RULE_LENGTH} characters`);
|
|
39
|
+
}
|
|
40
|
+
const rule = item.trim();
|
|
41
|
+
if (!rules.includes(rule)) rules.push(rule);
|
|
42
|
+
}
|
|
43
|
+
return rules;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function appAccessOption(value) {
|
|
47
|
+
if (value === undefined) return { default: "allow", allow: [], deny: [] };
|
|
48
|
+
if (!isRecord(value)) throw new Error("appAccess must be an object");
|
|
49
|
+
const defaultAccess = value.default === undefined ? "allow" : value.default;
|
|
50
|
+
if (typeof defaultAccess !== "string" || !["allow", "deny"].includes(defaultAccess.trim())) {
|
|
51
|
+
throw new Error("appAccess.default must be allow or deny");
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
default: defaultAccess.trim(),
|
|
55
|
+
allow: appRuleList(value.allow, "appAccess.allow"),
|
|
56
|
+
deny: appRuleList(value.deny, "appAccess.deny"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
27
60
|
function serverNameOption(value) {
|
|
28
61
|
const name = stringOption(value, DEFAULT_SERVER_NAME);
|
|
29
62
|
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) {
|
|
@@ -43,6 +76,10 @@ export function normalizeOptions(input = {}) {
|
|
|
43
76
|
if (backend === "custom" && !command) {
|
|
44
77
|
throw new Error("backend 'custom' requires a non-empty command array");
|
|
45
78
|
}
|
|
79
|
+
const safetyMode = stringOption(raw.safetyMode, "full");
|
|
80
|
+
if (!SAFETY_MODES.has(safetyMode)) {
|
|
81
|
+
throw new Error(`safetyMode must be one of: ${[...SAFETY_MODES].join(", ")}`);
|
|
82
|
+
}
|
|
46
83
|
|
|
47
84
|
return {
|
|
48
85
|
backend,
|
|
@@ -51,6 +88,8 @@ export function normalizeOptions(input = {}) {
|
|
|
51
88
|
allowGlobalPointerFallbacks: booleanOption(raw.allowGlobalPointerFallbacks, false),
|
|
52
89
|
override: booleanOption(raw.override, false),
|
|
53
90
|
serverName: serverNameOption(raw.serverName),
|
|
91
|
+
safetyMode,
|
|
92
|
+
appAccess: appAccessOption(raw.appAccess),
|
|
54
93
|
timeoutMs: boundedNumber(raw.timeoutMs, 30_000, 1_000, 300_000),
|
|
55
94
|
maxTextChars: boundedNumber(raw.maxTextChars, 200_000, 1_000, 2_000_000),
|
|
56
95
|
maxImageBytes: boundedNumber(raw.maxImageBytes, 2 * 1024 * 1024, 16_384, 16 * 1024 * 1024),
|
|
@@ -62,6 +101,8 @@ export function optionsToEnvironment(options) {
|
|
|
62
101
|
const environment = {
|
|
63
102
|
OPENCODE_COMPUTER_USE_BACKEND: normalized.backend,
|
|
64
103
|
OPENCODE_COMPUTER_USE_SERVER_NAME: normalized.serverName,
|
|
104
|
+
OPENCODE_COMPUTER_USE_SAFETY_MODE: normalized.safetyMode,
|
|
105
|
+
OPENCODE_COMPUTER_USE_APP_ACCESS: JSON.stringify(normalized.appAccess),
|
|
65
106
|
OPENCODE_COMPUTER_USE_TIMEOUT_MS: String(normalized.timeoutMs),
|
|
66
107
|
OPENCODE_COMPUTER_USE_MAX_TEXT_CHARS: String(normalized.maxTextChars),
|
|
67
108
|
OPENCODE_COMPUTER_USE_MAX_IMAGE_BYTES: String(normalized.maxImageBytes),
|
|
@@ -88,11 +129,22 @@ export function optionsFromEnvironment(environment = process.env) {
|
|
|
88
129
|
}
|
|
89
130
|
}
|
|
90
131
|
|
|
132
|
+
let appAccess;
|
|
133
|
+
if (environment.OPENCODE_COMPUTER_USE_APP_ACCESS) {
|
|
134
|
+
try {
|
|
135
|
+
appAccess = JSON.parse(environment.OPENCODE_COMPUTER_USE_APP_ACCESS);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new Error("OPENCODE_COMPUTER_USE_APP_ACCESS must be valid JSON");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
91
141
|
return normalizeOptions({
|
|
92
142
|
backend: environment.OPENCODE_COMPUTER_USE_BACKEND,
|
|
93
143
|
command,
|
|
94
144
|
allowGlobalPointerFallbacks: environment.OPEN_COMPUTER_USE_ALLOW_GLOBAL_POINTER_FALLBACKS === "1",
|
|
95
145
|
serverName: environment.OPENCODE_COMPUTER_USE_SERVER_NAME,
|
|
146
|
+
safetyMode: environment.OPENCODE_COMPUTER_USE_SAFETY_MODE,
|
|
147
|
+
appAccess,
|
|
96
148
|
timeoutMs: Number(environment.OPENCODE_COMPUTER_USE_TIMEOUT_MS),
|
|
97
149
|
maxTextChars: Number(environment.OPENCODE_COMPUTER_USE_MAX_TEXT_CHARS),
|
|
98
150
|
maxImageBytes: Number(environment.OPENCODE_COMPUTER_USE_MAX_IMAGE_BYTES),
|
|
@@ -105,6 +157,8 @@ export const defaults = Object.freeze({
|
|
|
105
157
|
allowGlobalPointerFallbacks: false,
|
|
106
158
|
override: false,
|
|
107
159
|
serverName: DEFAULT_SERVER_NAME,
|
|
160
|
+
safetyMode: "full",
|
|
161
|
+
appAccess: { default: "allow", allow: [], deny: [] },
|
|
108
162
|
timeoutMs: 30_000,
|
|
109
163
|
maxTextChars: 200_000,
|
|
110
164
|
maxImageBytes: 2 * 1024 * 1024,
|
package/lib/repl-kernel.mjs
CHANGED
|
@@ -14,7 +14,12 @@ const native = {
|
|
|
14
14
|
});
|
|
15
15
|
},
|
|
16
16
|
};
|
|
17
|
-
const session = new PersistentJavaScriptSession({
|
|
17
|
+
const session = new PersistentJavaScriptSession({
|
|
18
|
+
native,
|
|
19
|
+
limits: workerData,
|
|
20
|
+
safetyMode: workerData.safetyMode,
|
|
21
|
+
appAccess: workerData.appAccess,
|
|
22
|
+
});
|
|
18
23
|
|
|
19
24
|
parentPort.on("message", async message => {
|
|
20
25
|
if (message.type === "native_response") {
|
package/lib/repl.mjs
CHANGED
|
@@ -30,8 +30,11 @@ const SERVER_INSTRUCTIONS = `UI automation through a persistent JavaScript REPL
|
|
|
30
30
|
|
|
31
31
|
On the first call after startup or js_reset, bind the requested app with await cua.getApp("Example App"), or call await cua.getState() only when an app inventory is actually needed. App bindings persist across js calls. Batch deterministic actions and the resulting getAXState() in one js call, then verify the returned UI state. Use nodeRepl.write(value) for additional text and await nodeRepl.emitImage(image) for additional images. Ask the user before destructive or externally visible actions such as sending, deleting, or purchasing.`;
|
|
32
32
|
|
|
33
|
-
function jsDescription(timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
34
|
-
|
|
33
|
+
function jsDescription(timeoutMs = DEFAULT_TIMEOUT_MS, safetyMode = "full") {
|
|
34
|
+
const safetyNote = safetyMode === "read-only"
|
|
35
|
+
? " This session is read-only; mutating CUA methods are disabled."
|
|
36
|
+
: "";
|
|
37
|
+
return `Run JavaScript in a persistent Node.js REPL with top-level await and an initialized asynchronous cua API for Open Computer Use. Use this for all desktop interactions. Bind an app with let app = await cua.getApp("Example App"); then call await app.click(...), await app.typeText(...), and await app.getAXState(). Top-level bindings persist until js_reset, so prefer top-level var for names that may be assigned again. Batch deterministic actions and the resulting state read in one call to reduce round trips. Use nodeRepl.write(value) for extra text and await nodeRepl.emitImage(image) for extra images. If timeout_ms is omitted, execution times out after ${timeoutMs} ms.${safetyNote}`;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
const RESET_DESCRIPTION = `Reset the persistent Open Computer Use JavaScript session and discard all bindings created by prior js calls. The next js call starts with a freshly initialized cua API. This does not close native apps or erase their state.`;
|
|
@@ -217,9 +220,11 @@ export function createCuaApi(native, activeOutput, assertActive = () => {}) {
|
|
|
217
220
|
|
|
218
221
|
|
|
219
222
|
export class PersistentJavaScriptSession {
|
|
220
|
-
constructor({ native, limits = {} }) {
|
|
223
|
+
constructor({ native, limits = {}, safetyMode = "full", appAccess = {} }) {
|
|
221
224
|
this.native = native;
|
|
222
225
|
this.limits = outputLimits(limits);
|
|
226
|
+
this.safetyMode = safetyMode;
|
|
227
|
+
this.appAccess = appAccess;
|
|
223
228
|
this.active = null;
|
|
224
229
|
this.reset();
|
|
225
230
|
}
|
|
@@ -295,6 +300,7 @@ export class PersistentJavaScriptSession {
|
|
|
295
300
|
isActive,
|
|
296
301
|
schedule,
|
|
297
302
|
cancel,
|
|
303
|
+
{ safetyMode: this.safetyMode, appAccess: this.appAccess },
|
|
298
304
|
);
|
|
299
305
|
this.nodeRepl = this.context.nodeRepl;
|
|
300
306
|
this.cua = this.context.cua;
|
|
@@ -341,9 +347,12 @@ export class PersistentJavaScriptSession {
|
|
|
341
347
|
// loop can be terminated without hanging the MCP transport. Native tool calls
|
|
342
348
|
// are brokered back to the parent and still execute serially in the native MCP.
|
|
343
349
|
export class WorkerJavaScriptSession {
|
|
344
|
-
constructor({ native, limits = {} }) {
|
|
350
|
+
constructor({ native, limits = {}, safetyMode = "full", appAccess = {} }) {
|
|
345
351
|
this.native = native;
|
|
346
352
|
this.limits = outputLimits(limits);
|
|
353
|
+
this.safetyMode = safetyMode;
|
|
354
|
+
this.appAccess = appAccess;
|
|
355
|
+
this.workerData = { ...this.limits, safetyMode: this.safetyMode, appAccess: this.appAccess };
|
|
347
356
|
this.nextId = 1;
|
|
348
357
|
this.activeRequestId = null;
|
|
349
358
|
this.pending = new Map();
|
|
@@ -354,7 +363,7 @@ export class WorkerJavaScriptSession {
|
|
|
354
363
|
#start() {
|
|
355
364
|
const worker = new Worker(new URL("./repl-kernel.mjs", import.meta.url), {
|
|
356
365
|
execArgv: ["--experimental-repl-await"],
|
|
357
|
-
workerData: this.
|
|
366
|
+
workerData: this.workerData,
|
|
358
367
|
});
|
|
359
368
|
this.worker = worker;
|
|
360
369
|
worker.on("message", message => void this.#onMessage(worker, message));
|
|
@@ -491,11 +500,18 @@ export async function runServer({ command, args }) {
|
|
|
491
500
|
let shuttingDown;
|
|
492
501
|
let stopping = false;
|
|
493
502
|
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
494
|
-
const shutdown = () => {
|
|
503
|
+
const shutdown = (reason = "server_shutdown") => {
|
|
495
504
|
if (shuttingDown) return shuttingDown;
|
|
496
505
|
stopping = true;
|
|
497
506
|
const closeSession = session ? session.close() : Promise.resolve();
|
|
498
|
-
shuttingDown = closeSession.catch(() => {}).
|
|
507
|
+
shuttingDown = closeSession.catch(() => {}).then(async () => {
|
|
508
|
+
try {
|
|
509
|
+
await native.notifyAsync("notifications/turn-ended", { reason });
|
|
510
|
+
} catch {
|
|
511
|
+
// The native process may already be closing; local cleanup still continues.
|
|
512
|
+
}
|
|
513
|
+
return native.close().catch(() => {});
|
|
514
|
+
});
|
|
499
515
|
return shuttingDown;
|
|
500
516
|
};
|
|
501
517
|
const enqueue = (line) => {
|
|
@@ -505,7 +521,7 @@ export async function runServer({ command, args }) {
|
|
|
505
521
|
const rejectOversizedInput = () => {
|
|
506
522
|
buffer = "";
|
|
507
523
|
send({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Request frame exceeded the input size limit" } });
|
|
508
|
-
void shutdown();
|
|
524
|
+
void shutdown("input_limit");
|
|
509
525
|
};
|
|
510
526
|
const onInput = (chunk) => {
|
|
511
527
|
if (stopping) return;
|
|
@@ -527,15 +543,20 @@ export async function runServer({ command, args }) {
|
|
|
527
543
|
|
|
528
544
|
try {
|
|
529
545
|
await native.initialize();
|
|
530
|
-
session = new WorkerJavaScriptSession({
|
|
546
|
+
session = new WorkerJavaScriptSession({
|
|
547
|
+
native,
|
|
548
|
+
limits: options,
|
|
549
|
+
safetyMode: options.safetyMode,
|
|
550
|
+
appAccess: options.appAccess,
|
|
551
|
+
});
|
|
531
552
|
process.stdin.setEncoding("utf8");
|
|
532
553
|
process.stdin.on("data", onInput);
|
|
533
554
|
process.stdin.on("error", error => {
|
|
534
555
|
console.error(safeErrorMessage(error));
|
|
535
|
-
void shutdown();
|
|
556
|
+
void shutdown("stdin_error");
|
|
536
557
|
});
|
|
537
558
|
} catch (error) {
|
|
538
|
-
await shutdown();
|
|
559
|
+
await shutdown("startup_error");
|
|
539
560
|
throw error;
|
|
540
561
|
}
|
|
541
562
|
|
|
@@ -565,7 +586,7 @@ export async function runServer({ command, args }) {
|
|
|
565
586
|
});
|
|
566
587
|
}
|
|
567
588
|
if (method === "ping") return send({ jsonrpc: "2.0", id, result: {} });
|
|
568
|
-
if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: toolDefinitions(options.timeoutMs) } });
|
|
589
|
+
if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: toolDefinitions(options.timeoutMs, options) } });
|
|
569
590
|
if (method === "tools/call") {
|
|
570
591
|
if (!isJsonRecord(params) || typeof params.name !== "string") throw new Error("tools/call requires a tool name");
|
|
571
592
|
if (params.arguments !== undefined && !isJsonRecord(params.arguments)) throw new Error("tools/call arguments must be an object");
|
|
@@ -582,7 +603,12 @@ export async function runServer({ command, args }) {
|
|
|
582
603
|
if (typeof code !== "string" || code.length > MAX_CODE_CHARS) {
|
|
583
604
|
throw new Error(`js code must be a string no longer than ${MAX_CODE_CHARS} characters`);
|
|
584
605
|
}
|
|
606
|
+
const title = params.arguments?.title;
|
|
607
|
+
if (title !== undefined && (typeof title !== "string" || !title.trim() || title.length > 80)) {
|
|
608
|
+
throw new Error("js title must be a non-empty string no longer than 80 characters");
|
|
609
|
+
}
|
|
585
610
|
const result = boundToolResult(await session.run(code, timeoutMs), options);
|
|
611
|
+
if (title) result._meta = { ...(result._meta || {}), title: title.trim() };
|
|
586
612
|
return send({ jsonrpc: "2.0", id, result });
|
|
587
613
|
}
|
|
588
614
|
throw new Error(`Unsupported tool: ${params.name}`);
|
|
@@ -595,17 +621,20 @@ export async function runServer({ command, args }) {
|
|
|
595
621
|
|
|
596
622
|
const shutdownSignals = process.platform === "win32" ? ["SIGINT", "SIGTERM"] : ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
597
623
|
for (const signal of shutdownSignals) {
|
|
598
|
-
process.on(signal, () => { void shutdown().finally(() => process.exit(0)); });
|
|
624
|
+
process.on(signal, () => { void shutdown("signal").finally(() => process.exit(0)); });
|
|
599
625
|
}
|
|
600
|
-
process.stdin.on("end", () => { void requestQueue.finally(shutdown); });
|
|
626
|
+
process.stdin.on("end", () => { void requestQueue.finally(() => shutdown("stdin_closed")); });
|
|
601
627
|
}
|
|
602
628
|
|
|
603
|
-
export function toolDefinitions(defaultTimeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
629
|
+
export function toolDefinitions(defaultTimeoutMs = DEFAULT_TIMEOUT_MS, { safetyMode = "full" } = {}) {
|
|
604
630
|
const requestedTimeout = Number.isFinite(defaultTimeoutMs) ? defaultTimeoutMs : DEFAULT_TIMEOUT_MS;
|
|
605
631
|
const timeoutMs = Math.min(Math.max(requestedTimeout, 1_000), MAX_TIMEOUT_MS);
|
|
606
|
-
const
|
|
632
|
+
const readOnly = safetyMode === "read-only";
|
|
633
|
+
const annotations = readOnly
|
|
634
|
+
? { destructiveHint: false, openWorldHint: false, readOnlyHint: true }
|
|
635
|
+
: { destructiveHint: true, openWorldHint: true, readOnlyHint: false };
|
|
607
636
|
return [
|
|
608
|
-
{ name: "js", description: jsDescription(timeoutMs), annotations, inputSchema: { type: "object", additionalProperties: false, properties: { code: { type: "string", minLength: 1, maxLength: MAX_CODE_CHARS, description: "JavaScript source to execute with top-level await and the initialized cua API." }, timeout_ms: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS, description: `Optional timeout in milliseconds. Defaults to ${timeoutMs} and is capped at 300000.` }, title: { type: "string", minLength: 1, maxLength: 80, description: "Short user-facing description of what this code is doing." } }, required: ["code"] } },
|
|
637
|
+
{ name: "js", description: jsDescription(timeoutMs, safetyMode), annotations, inputSchema: { type: "object", additionalProperties: false, properties: { code: { type: "string", minLength: 1, maxLength: MAX_CODE_CHARS, description: "JavaScript source to execute with top-level await and the initialized cua API." }, timeout_ms: { type: "integer", minimum: 1, maximum: MAX_TIMEOUT_MS, description: `Optional timeout in milliseconds. Defaults to ${timeoutMs} and is capped at 300000.` }, title: { type: "string", minLength: 1, maxLength: 80, description: "Short user-facing description of what this code is doing." } }, required: ["code"] } },
|
|
609
638
|
{ name: "js_reset", description: RESET_DESCRIPTION, annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true, idempotentHint: true }, inputSchema: { type: "object", additionalProperties: false, properties: {} } },
|
|
610
639
|
];
|
|
611
640
|
}
|
package/lib/sandbox.mjs
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
import { runInContext } from "node:vm";
|
|
2
2
|
|
|
3
3
|
const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
|
4
|
+
const MAX_APP_BINDINGS = 128;
|
|
5
|
+
const MUTATING_APP_METHODS = new Set([
|
|
6
|
+
"click",
|
|
7
|
+
"drag",
|
|
8
|
+
"pressKey",
|
|
9
|
+
"scroll",
|
|
10
|
+
"setValue",
|
|
11
|
+
"typeText",
|
|
12
|
+
"performSecondaryAction",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function normalizedAppAccess(value) {
|
|
16
|
+
const policy = value && typeof value === "object" ? value : {};
|
|
17
|
+
const defaultAccess = policy.default === "deny" ? "deny" : "allow";
|
|
18
|
+
const normalizeRules = (rules) => Array.isArray(rules)
|
|
19
|
+
? rules.filter((rule) => typeof rule === "string" && rule.trim()).map((rule) => rule.trim().toLowerCase())
|
|
20
|
+
: [];
|
|
21
|
+
return {
|
|
22
|
+
default: defaultAccess,
|
|
23
|
+
allow: new Set(normalizeRules(policy.allow)),
|
|
24
|
+
deny: new Set(normalizeRules(policy.deny)),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function appMatches(app, rules) {
|
|
29
|
+
if (!app || typeof app !== "object") return false;
|
|
30
|
+
return [app.id, app.bundleId, app.name, app.displayName]
|
|
31
|
+
.filter((value) => typeof value === "string" && value.trim())
|
|
32
|
+
.some((value) => rules.has(value.trim().toLowerCase()));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function appNameMatches(app, name) {
|
|
36
|
+
if (!app || typeof app !== "object") return false;
|
|
37
|
+
const expected = String(name).trim().toLowerCase();
|
|
38
|
+
return [app.id, app.bundleId, app.name, app.displayName]
|
|
39
|
+
.filter((value) => typeof value === "string" && value.trim())
|
|
40
|
+
.some((value) => value.trim().toLowerCase() === expected);
|
|
41
|
+
}
|
|
4
42
|
|
|
5
43
|
const SANDBOX_FACTORY_SOURCE = String.raw`
|
|
6
44
|
((dispatch, write, emitImage, schedule, cancel) => {
|
|
@@ -237,22 +275,56 @@ function encodeSandboxResult(value) {
|
|
|
237
275
|
return payload;
|
|
238
276
|
}
|
|
239
277
|
|
|
240
|
-
export function createSandboxBridge(
|
|
278
|
+
export function createSandboxBridge(
|
|
279
|
+
api,
|
|
280
|
+
context,
|
|
281
|
+
activeOutput,
|
|
282
|
+
isActive,
|
|
283
|
+
schedule,
|
|
284
|
+
cancel,
|
|
285
|
+
{ safetyMode = "full", appAccess = {} } = {},
|
|
286
|
+
) {
|
|
241
287
|
const bindings = new Map();
|
|
288
|
+
const accessPolicy = normalizedAppAccess(appAccess);
|
|
242
289
|
let nextBinding = 1;
|
|
290
|
+
const isReadOnly = safetyMode === "read-only";
|
|
291
|
+
const appIsAllowed = (app) => {
|
|
292
|
+
if (appMatches(app, accessPolicy.deny)) return false;
|
|
293
|
+
if (accessPolicy.allow.size > 0) return appMatches(app, accessPolicy.allow);
|
|
294
|
+
return accessPolicy.default === "allow";
|
|
295
|
+
};
|
|
296
|
+
const assertNamedAppAllowed = async (name) => {
|
|
297
|
+
if (accessPolicy.allow.size === 0 && accessPolicy.deny.size === 0) return;
|
|
298
|
+
const apps = await api.listApps({ emit: false });
|
|
299
|
+
const matches = Array.isArray(apps) ? apps.filter((app) => appNameMatches(app, name)) : [];
|
|
300
|
+
if (matches.length > 0) {
|
|
301
|
+
if (matches.some((app) => !appIsAllowed(app))) {
|
|
302
|
+
throw new Error(`app access denied by policy: ${name}`);
|
|
303
|
+
}
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!appIsAllowed({ id: name, displayName: name })) {
|
|
307
|
+
throw new Error(`app access denied by policy: ${name}`);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const filterApps = (apps) => Array.isArray(apps) ? apps.filter(appIsAllowed) : [];
|
|
243
311
|
const dispatch = async (method, args) => {
|
|
244
312
|
try {
|
|
245
313
|
if (!isActive()) throw new Error("CUA calls are only available while js is executing");
|
|
246
314
|
if (method === "getState") {
|
|
247
315
|
const state = await api.getState({ emit: false });
|
|
248
|
-
return encodeSandboxResult({ apps: state.apps });
|
|
316
|
+
return encodeSandboxResult({ apps: filterApps(state.apps) });
|
|
249
317
|
}
|
|
250
|
-
if (method === "listApps") return encodeSandboxResult(await api.listApps({ emit: false }));
|
|
318
|
+
if (method === "listApps") return encodeSandboxResult(filterApps(await api.listApps({ emit: false })));
|
|
251
319
|
if (method === "getApp") {
|
|
252
320
|
const app = args[0];
|
|
253
321
|
if (typeof app !== "string" || !app.trim()) {
|
|
254
322
|
throw new Error("cua.getApp expects a non-empty app name or bundle ID");
|
|
255
323
|
}
|
|
324
|
+
await assertNamedAppAllowed(app);
|
|
325
|
+
if (bindings.size >= MAX_APP_BINDINGS) {
|
|
326
|
+
throw new Error(`CUA app binding limit reached (${MAX_APP_BINDINGS}); call js_reset`);
|
|
327
|
+
}
|
|
256
328
|
const binding = await api.getApp(app);
|
|
257
329
|
const token = `app-${nextBinding++}`;
|
|
258
330
|
bindings.set(token, binding);
|
|
@@ -263,10 +335,14 @@ export function createSandboxBridge(api, context, activeOutput, isActive, schedu
|
|
|
263
335
|
return encodeSandboxResult(undefined);
|
|
264
336
|
}
|
|
265
337
|
if (method.startsWith("app:")) {
|
|
338
|
+
const methodName = method.slice(4);
|
|
339
|
+
if (isReadOnly && MUTATING_APP_METHODS.has(methodName)) {
|
|
340
|
+
throw new Error(`mutating CUA method disabled in read-only safety mode: ${methodName}`);
|
|
341
|
+
}
|
|
266
342
|
const [token, ...callArgs] = args;
|
|
267
343
|
const binding = bindings.get(token);
|
|
268
344
|
if (!binding) throw new Error("unknown CUA app binding");
|
|
269
|
-
const result = await binding[
|
|
345
|
+
const result = await binding[methodName](...callArgs);
|
|
270
346
|
return encodeSandboxResult(result);
|
|
271
347
|
}
|
|
272
348
|
throw new Error(`unknown sandbox method: ${method}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontendxlab/opencode-computer-use",
|
|
3
|
-
"version": "0.1.
|
|
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": ">=
|
|
46
|
+
"opencode": ">=0.0.0-beta-18684"
|
|
46
47
|
},
|
|
47
48
|
"scripts": {
|
|
48
|
-
"
|
|
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
|
}
|