@opencode/plugin-browser 0.0.0-reserved → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -4
- package/dist/connection.d.ts +234 -0
- package/dist/connection.js +133 -0
- package/dist/files.d.ts +18 -0
- package/dist/files.js +89 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +11 -0
- package/dist/proxy.d.ts +19 -0
- package/dist/proxy.js +294 -0
- package/dist/rpc.d.ts +2479 -0
- package/dist/rpc.js +322 -0
- package/dist/tools.d.ts +234 -0
- package/dist/tools.js +104 -0
- package/dist/tunnel.d.ts +16 -0
- package/dist/tunnel.js +125 -0
- package/package.json +37 -3
package/README.md
CHANGED
|
@@ -1,5 +1,128 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Browser plugin
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
`@opencode/plugin-browser` exposes the desktop browser through Code Mode.
|
|
4
|
+
The server owns tools, invocation scope, and permissions; the desktop owns tabs,
|
|
5
|
+
CDP, captured traffic, evaluations, and capture files. Core only registers the
|
|
6
|
+
plugin. Neither endpoint imports the other's implementation.
|
|
7
|
+
|
|
8
|
+
```js
|
|
9
|
+
const tab = await tools.browser.tabs.open({ url: "https://example.com" })
|
|
10
|
+
return await tools.browser.snapshot({ tabID: tab.id })
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
All page operations require a `tabID` returned by `browser.tabs.open/list`.
|
|
14
|
+
Focus selects the visible Review tab, not an implicit command target. Discover
|
|
15
|
+
current signatures with `search({ namespace: "browser" })`.
|
|
16
|
+
Screenshots require a focused, visible tab; call `browser.tabs.focus` first.
|
|
17
|
+
|
|
18
|
+
## Tools
|
|
19
|
+
|
|
20
|
+
- Tabs: `tabs.list`, `tabs.open`, `tabs.focus`, `tabs.close`.
|
|
21
|
+
- Navigation: `navigate`, `back`, `forward`, `reload`, `stop`, `frames`.
|
|
22
|
+
- Observation: `snapshot`, `find`, `evaluate`, `wait`, `screenshot`.
|
|
23
|
+
- Input: `click`, `hover`, `drag`, `fill`, `fill_form`, `select`, `check`, `press`, `scroll`, `dialog`.
|
|
24
|
+
- Files: `files.upload`, `files.drop`, `files.list`, `files.get`.
|
|
25
|
+
- Diagnostics: `console`, `network.list`, `network.get`.
|
|
26
|
+
- Performance: `trace.start`, `trace.stop`, `trace.analyze`, `cpu.start`, `cpu.stop`, `cpu.analyze`.
|
|
27
|
+
- Memory: `heap.snapshot`, `heap.summary`, `heap.query`, `heap.object`, `heap.compare`.
|
|
28
|
+
- Audits: `lighthouse` (accessibility, SEO, best practices).
|
|
29
|
+
|
|
30
|
+
The source of truth for inputs, descriptions, and outputs is
|
|
31
|
+
`Browser.Operations` in `@opencode/plugin-browser/rpc`.
|
|
32
|
+
|
|
33
|
+
The plugin entrypoint only composes its two owners: `connection.ts` manages
|
|
34
|
+
desktop attachments and pending RPC requests; `tools.ts` runs the tool workflow.
|
|
35
|
+
Server-local file IO stays in `files.ts`. The public `rpc.ts` entrypoint remains
|
|
36
|
+
pure and does not load any of these runtime modules.
|
|
37
|
+
|
|
38
|
+
## Tests
|
|
39
|
+
|
|
40
|
+
Run `bun test` and `bun typecheck` from this package for its contract checks.
|
|
41
|
+
Native browser coverage lives with the desktop implementation
|
|
42
|
+
(`packages/desktop/test/browser-native.test.ts`), not in this package.
|
|
43
|
+
|
|
44
|
+
## RPC
|
|
45
|
+
|
|
46
|
+
The plugin-owned contract is `@opencode/plugin-browser/rpc`. This entrypoint
|
|
47
|
+
contains only schemas and descriptions; it does not load the server plugin or
|
|
48
|
+
filesystem code. The desktop subscribes
|
|
49
|
+
to control events before starting `attach` with `version: 4`. The attachment call
|
|
50
|
+
stays pending for its lifetime. A matching `attached` event is the readiness barrier.
|
|
51
|
+
|
|
52
|
+
- `state` publishes the authoritative tab inventory. Tab lookups read only this
|
|
53
|
+
inventory, so the desktop must publish `state` and wait for its acknowledgment
|
|
54
|
+
before sending `result` for `tabs.open` or `tabs.close`.
|
|
55
|
+
- `control` announces a request ID or cancellation; it never broadcasts arguments,
|
|
56
|
+
script source, file bytes, or browser results on the server-wide event feed.
|
|
57
|
+
- `command` retrieves the pending request through authenticated RPC.
|
|
58
|
+
- `result` completes it. The plugin validates the selected operation's output.
|
|
59
|
+
- Inspection commands return only target/source metadata. Execution checks that
|
|
60
|
+
the approved target has not changed while permission was pending.
|
|
61
|
+
- `attach` returns `replaced` when another desktop takes ownership. That is not
|
|
62
|
+
a retryable disconnect; the old desktop must not reclaim the session automatically.
|
|
63
|
+
|
|
64
|
+
The connection ID is correlation, not separate client authentication. Requests
|
|
65
|
+
are bound to their attachment and tab. Disconnect, replacement, session movement,
|
|
66
|
+
and unload fail outstanding work. Calls are not replayed automatically: a lost
|
|
67
|
+
response does not prove that a click or evaluation never happened.
|
|
68
|
+
|
|
69
|
+
## Files and remote servers
|
|
70
|
+
|
|
71
|
+
Upload paths are **server-local**. File bytes cross RPC and the desktop writes its
|
|
72
|
+
own temporary copy. Captures/downloads travel back as bounded bytes and are saved
|
|
73
|
+
to server-local temporary files. Returned `files[].path` values refer to that
|
|
74
|
+
server; bytes are not included in the model's structured output. Images are also
|
|
75
|
+
attached for the model to inspect. Temporary exports are not deleted on plugin
|
|
76
|
+
reload, so a returned path remains usable; they follow the host's temporary-file
|
|
77
|
+
lifetime.
|
|
78
|
+
|
|
79
|
+
Each transfer is limited to 5 MiB total. There is no shared filesystem assumption,
|
|
80
|
+
resumable file-transfer service or object store. Browsing uses the connected
|
|
81
|
+
server's network: `localhost:8000` reaches that server's port 8000, while Chromium
|
|
82
|
+
and page JavaScript still run on the desktop. Dev-server ports need not be public.
|
|
83
|
+
|
|
84
|
+
`tunnel.open/read/write/close` relay bounded TCP chunks through the existing
|
|
85
|
+
authenticated plugin RPC route. The desktop-only `/proxy` entrypoint adapts
|
|
86
|
+
Chromium's HTTP/CONNECT proxy traffic, including WebSockets, to those methods.
|
|
87
|
+
Network bytes never go onto the global event stream. Attachment closure releases
|
|
88
|
+
the sockets; failed writes are not replayed and there is no direct-network fallback.
|
|
89
|
+
The tunnel relays whatever a loaded page requests and is not filtered per request:
|
|
90
|
+
page traffic has the server host's network reach, including its loopback and LAN.
|
|
91
|
+
|
|
92
|
+
Remote endpoints can use HTTPS and the existing server credentials. A reverse
|
|
93
|
+
proxy must allow long-lived event and attachment requests; the attachment RPC
|
|
94
|
+
stays open rather than sending response-body heartbeats.
|
|
95
|
+
|
|
96
|
+
Lighthouse audits use snapshot mode without changing device emulation or adding
|
|
97
|
+
an embedded report screenshot; use `browser.screenshot` for images. Trace exports
|
|
98
|
+
contain the target renderer process, not the whole desktop application. A tab
|
|
99
|
+
process change or trace-buffer loss is reported as an incomplete capture. Heap
|
|
100
|
+
summaries report shallow size, not computed retained size, and do not prove leaks.
|
|
101
|
+
|
|
102
|
+
All page-derived data is untrusted, including structured outputs. Schema
|
|
103
|
+
validation does not make page text an instruction or grant it authority.
|
|
104
|
+
|
|
105
|
+
## Recovering from errors
|
|
106
|
+
|
|
107
|
+
Errors name the failed operation and the next supported action. Refresh tab IDs
|
|
108
|
+
with `browser.tabs.list`, element refs with `browser.snapshot`, and frame IDs with
|
|
109
|
+
`browser.frames`. File and network request IDs must come from the same tab's
|
|
110
|
+
current listing. Trace, CPU, and heap files are not interchangeable.
|
|
111
|
+
|
|
112
|
+
A timeout, cancellation, or disconnection does not prove the action never ran.
|
|
113
|
+
Inspect the tab and completed files before repeating clicks, uploads, submissions,
|
|
114
|
+
or evaluations. Do not retry a permission denial through another tool or weaken
|
|
115
|
+
browser security to work around a TLS or unsupported-operation error.
|
|
116
|
+
|
|
117
|
+
File errors distinguish server-local upload paths from desktop capture files.
|
|
118
|
+
Pending/failed downloads and unavailable response bodies are not empty files.
|
|
119
|
+
Oversized output requires a smaller request or capture, not an identical retry.
|
|
120
|
+
|
|
121
|
+
Per-URL and server-file permission checks belong to the final permission layer
|
|
122
|
+
(#46530). This base plugin layer intentionally does not enforce those rules.
|
|
123
|
+
|
|
124
|
+
Disable through normal configuration:
|
|
125
|
+
|
|
126
|
+
```jsonc
|
|
127
|
+
{ "plugins": ["-opencode.browser"] }
|
|
128
|
+
```
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
export * as BrowserConnection from "./connection.js";
|
|
2
|
+
import type { Context } from "@opencode/plugin/effect/plugin";
|
|
3
|
+
import { Tool } from "@opencode/schema/tool";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { Browser } from "./rpc.js";
|
|
6
|
+
export type Connection = Effect.Success<ReturnType<typeof make>>;
|
|
7
|
+
export declare const make: (ctx: Pick<Context, "location" | "rpc" | "event" | "session">) => Effect.Effect<{
|
|
8
|
+
target: (sessionID: string & import("effect/Brand").Brand<"SessionID">, action: {
|
|
9
|
+
readonly type: "tabs.list";
|
|
10
|
+
} | {
|
|
11
|
+
readonly type: "tabs.open";
|
|
12
|
+
readonly url?: string | undefined;
|
|
13
|
+
readonly focus?: boolean | undefined;
|
|
14
|
+
} | {
|
|
15
|
+
readonly type: "tabs.focus";
|
|
16
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
17
|
+
} | {
|
|
18
|
+
readonly type: "tabs.close";
|
|
19
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
20
|
+
} | {
|
|
21
|
+
readonly type: "navigate";
|
|
22
|
+
readonly url: string;
|
|
23
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
24
|
+
} | {
|
|
25
|
+
readonly type: "back";
|
|
26
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
27
|
+
} | {
|
|
28
|
+
readonly type: "forward";
|
|
29
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
30
|
+
} | {
|
|
31
|
+
readonly type: "reload";
|
|
32
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
33
|
+
} | {
|
|
34
|
+
readonly type: "stop";
|
|
35
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
36
|
+
} | {
|
|
37
|
+
readonly type: "frames";
|
|
38
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
39
|
+
} | {
|
|
40
|
+
readonly type: "snapshot";
|
|
41
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
42
|
+
readonly ref?: (string & import("effect/Brand").Brand<"Browser.Ref">) | undefined;
|
|
43
|
+
readonly depth?: number | undefined;
|
|
44
|
+
readonly boxes?: boolean | undefined;
|
|
45
|
+
readonly frameID?: string | undefined;
|
|
46
|
+
} | {
|
|
47
|
+
readonly type: "find";
|
|
48
|
+
readonly text: string;
|
|
49
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
50
|
+
readonly frameID?: string | undefined;
|
|
51
|
+
} | {
|
|
52
|
+
readonly type: "evaluate";
|
|
53
|
+
readonly script: string;
|
|
54
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
55
|
+
readonly frameID?: string | undefined;
|
|
56
|
+
} | {
|
|
57
|
+
readonly type: "click";
|
|
58
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
59
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
60
|
+
readonly count?: 2 | 1 | undefined;
|
|
61
|
+
readonly button?: "left" | "right" | "middle" | undefined;
|
|
62
|
+
readonly modifiers?: readonly ("Alt" | "Control" | "Meta" | "Shift")[] | undefined;
|
|
63
|
+
} | {
|
|
64
|
+
readonly type: "hover";
|
|
65
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
66
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
67
|
+
} | {
|
|
68
|
+
readonly type: "drag";
|
|
69
|
+
readonly from: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
70
|
+
readonly to: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
71
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
72
|
+
} | {
|
|
73
|
+
readonly type: "fill";
|
|
74
|
+
readonly text: string;
|
|
75
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
76
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
77
|
+
} | {
|
|
78
|
+
readonly type: "fill_form";
|
|
79
|
+
readonly fields: readonly ({
|
|
80
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
81
|
+
readonly type: "text";
|
|
82
|
+
readonly value: string;
|
|
83
|
+
} | {
|
|
84
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
85
|
+
readonly type: "select";
|
|
86
|
+
readonly values: readonly string[];
|
|
87
|
+
} | {
|
|
88
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
89
|
+
readonly type: "check";
|
|
90
|
+
readonly checked: boolean;
|
|
91
|
+
})[];
|
|
92
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
93
|
+
} | {
|
|
94
|
+
readonly type: "select";
|
|
95
|
+
readonly values: readonly string[];
|
|
96
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
97
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
98
|
+
} | {
|
|
99
|
+
readonly type: "check";
|
|
100
|
+
readonly checked: boolean;
|
|
101
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
102
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
103
|
+
} | {
|
|
104
|
+
readonly type: "press";
|
|
105
|
+
readonly key: string;
|
|
106
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
107
|
+
} | {
|
|
108
|
+
readonly type: "scroll";
|
|
109
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
110
|
+
readonly deltaY: number;
|
|
111
|
+
readonly deltaX?: number | undefined;
|
|
112
|
+
} | {
|
|
113
|
+
readonly type: "wait";
|
|
114
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
115
|
+
readonly condition: "text" | "load" | "textGone";
|
|
116
|
+
readonly text?: string | undefined;
|
|
117
|
+
readonly frameID?: string | undefined;
|
|
118
|
+
readonly timeoutMs?: number | undefined;
|
|
119
|
+
} | {
|
|
120
|
+
readonly type: "screenshot";
|
|
121
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
122
|
+
readonly format?: "png" | "jpeg" | "webp" | undefined;
|
|
123
|
+
readonly ref?: (string & import("effect/Brand").Brand<"Browser.Ref">) | undefined;
|
|
124
|
+
readonly fullPage?: boolean | undefined;
|
|
125
|
+
readonly quality?: number | undefined;
|
|
126
|
+
readonly maxWidth?: number | undefined;
|
|
127
|
+
} | {
|
|
128
|
+
readonly type: "dialog";
|
|
129
|
+
readonly action: "get" | "accept" | "dismiss";
|
|
130
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
131
|
+
readonly promptText?: string | undefined;
|
|
132
|
+
} | {
|
|
133
|
+
readonly type: "files.upload";
|
|
134
|
+
readonly paths: readonly string[];
|
|
135
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
136
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
137
|
+
} | {
|
|
138
|
+
readonly type: "files.drop";
|
|
139
|
+
readonly paths: readonly string[];
|
|
140
|
+
readonly ref: string & import("effect/Brand").Brand<"Browser.Ref">;
|
|
141
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
142
|
+
} | {
|
|
143
|
+
readonly type: "files.list";
|
|
144
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
145
|
+
} | {
|
|
146
|
+
readonly type: "files.get";
|
|
147
|
+
readonly fileID: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
148
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
149
|
+
} | {
|
|
150
|
+
readonly type: "console";
|
|
151
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
152
|
+
readonly limit?: number | undefined;
|
|
153
|
+
readonly level?: "error" | "info" | "warning" | "debug" | undefined;
|
|
154
|
+
} | {
|
|
155
|
+
readonly type: "network.list";
|
|
156
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
157
|
+
readonly limit?: number | undefined;
|
|
158
|
+
readonly resourceType?: "websocket" | "image" | "media" | "document" | "fetch" | "stylesheet" | "font" | "script" | "xhr" | "eventsource" | "manifest" | "other" | undefined;
|
|
159
|
+
readonly urlContains?: string | undefined;
|
|
160
|
+
} | {
|
|
161
|
+
readonly id: string;
|
|
162
|
+
readonly type: "network.get";
|
|
163
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
164
|
+
readonly includeBody?: boolean | undefined;
|
|
165
|
+
readonly maxBodyChars?: number | undefined;
|
|
166
|
+
} | {
|
|
167
|
+
readonly type: "trace.start";
|
|
168
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
169
|
+
readonly durationMs?: number | undefined;
|
|
170
|
+
} | {
|
|
171
|
+
readonly type: "trace.stop";
|
|
172
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
173
|
+
} | {
|
|
174
|
+
readonly type: "trace.analyze";
|
|
175
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
176
|
+
readonly fileID: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
177
|
+
readonly limit?: number | undefined;
|
|
178
|
+
} | {
|
|
179
|
+
readonly type: "cpu.start";
|
|
180
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
181
|
+
} | {
|
|
182
|
+
readonly type: "cpu.stop";
|
|
183
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
184
|
+
} | {
|
|
185
|
+
readonly type: "cpu.analyze";
|
|
186
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
187
|
+
readonly fileID: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
188
|
+
readonly limit?: number | undefined;
|
|
189
|
+
} | {
|
|
190
|
+
readonly type: "heap.snapshot";
|
|
191
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
192
|
+
} | {
|
|
193
|
+
readonly type: "heap.summary";
|
|
194
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
195
|
+
readonly fileID: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
196
|
+
readonly limit?: number | undefined;
|
|
197
|
+
} | {
|
|
198
|
+
readonly type: "heap.query";
|
|
199
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
200
|
+
readonly fileID: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
201
|
+
readonly name?: string | undefined;
|
|
202
|
+
readonly limit?: number | undefined;
|
|
203
|
+
} | {
|
|
204
|
+
readonly id: number;
|
|
205
|
+
readonly type: "heap.object";
|
|
206
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
207
|
+
readonly fileID: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
208
|
+
readonly limit?: number | undefined;
|
|
209
|
+
} | {
|
|
210
|
+
readonly type: "heap.compare";
|
|
211
|
+
readonly before: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
212
|
+
readonly after: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
213
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
214
|
+
readonly limit?: number | undefined;
|
|
215
|
+
} | {
|
|
216
|
+
readonly type: "lighthouse";
|
|
217
|
+
readonly tabID: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
218
|
+
}) => Effect.Effect<{
|
|
219
|
+
tab: {
|
|
220
|
+
readonly id: string & import("effect/Brand").Brand<"Browser.TabID">;
|
|
221
|
+
readonly url: string;
|
|
222
|
+
readonly title: string;
|
|
223
|
+
readonly loading: boolean;
|
|
224
|
+
readonly canGoBack: boolean;
|
|
225
|
+
readonly canGoForward: boolean;
|
|
226
|
+
readonly generation: number;
|
|
227
|
+
} | undefined;
|
|
228
|
+
inspect: () => Effect.Effect<{
|
|
229
|
+
readonly resources: readonly string[];
|
|
230
|
+
readonly key: string;
|
|
231
|
+
}, Tool.Error, never>;
|
|
232
|
+
request: (files: readonly Browser.File[], target?: Browser.Target) => Effect.Effect<Browser.Result, Tool.Error, never>;
|
|
233
|
+
}, Tool.Error, never>;
|
|
234
|
+
}, never, import("effect/Scope").Scope>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export * as BrowserConnection from "./connection.js";
|
|
2
|
+
import { Tool } from "@opencode/schema/tool";
|
|
3
|
+
import { Deferred, Effect, Schema, Stream } from "effect";
|
|
4
|
+
import { Browser } from "./rpc.js";
|
|
5
|
+
import { BrowserTunnel } from "./tunnel.js";
|
|
6
|
+
export const make = Effect.fn("BrowserConnection.make")(function* (ctx) {
|
|
7
|
+
const browsers = new Map();
|
|
8
|
+
let active = true;
|
|
9
|
+
const close = (sessionID, reason = "closed") => Effect.gen(function* () {
|
|
10
|
+
const browser = browsers.get(sessionID);
|
|
11
|
+
if (!browser)
|
|
12
|
+
return;
|
|
13
|
+
browsers.delete(sessionID);
|
|
14
|
+
browser.tunnels.dispose();
|
|
15
|
+
yield* Deferred.succeed(browser.closed, reason);
|
|
16
|
+
});
|
|
17
|
+
yield* Effect.addFinalizer(() => {
|
|
18
|
+
active = false;
|
|
19
|
+
return Effect.forEach(browsers.keys(), (id) => close(id), { discard: true });
|
|
20
|
+
});
|
|
21
|
+
const tunnels = (input) => {
|
|
22
|
+
const browser = browsers.get(input.sessionID);
|
|
23
|
+
return browser?.connectionID === input.connectionID
|
|
24
|
+
? Effect.succeed(browser.tunnels)
|
|
25
|
+
: Effect.fail(new Error("Browser attachment is unavailable; its network connections were closed."));
|
|
26
|
+
};
|
|
27
|
+
const rpc = yield* ctx.rpc
|
|
28
|
+
.register(Browser.Definition, {
|
|
29
|
+
attach: (input, call) => Effect.gen(function* () {
|
|
30
|
+
const session = yield* ctx.session
|
|
31
|
+
.get({ sessionID: input.sessionID })
|
|
32
|
+
.pipe(Effect.mapError(() => call.error("unavailable", "Session not found.", {})));
|
|
33
|
+
if (session.location.directory !== ctx.location.directory ||
|
|
34
|
+
session.location.workspaceID !== ctx.location.workspaceID)
|
|
35
|
+
return yield* Effect.fail(call.error("unavailable", "Session belongs to another location.", {}));
|
|
36
|
+
const browser = yield* Effect.acquireRelease(Effect.gen(function* () {
|
|
37
|
+
if (!active)
|
|
38
|
+
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}));
|
|
39
|
+
yield* close(input.sessionID, "replaced");
|
|
40
|
+
const browser = {
|
|
41
|
+
connectionID: input.connectionID,
|
|
42
|
+
state: { tabs: [], focusedTabID: null },
|
|
43
|
+
closed: yield* Deferred.make(),
|
|
44
|
+
pending: new Map(),
|
|
45
|
+
tunnels: BrowserTunnel.make(),
|
|
46
|
+
};
|
|
47
|
+
browsers.set(input.sessionID, browser);
|
|
48
|
+
return browser;
|
|
49
|
+
}), (browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void));
|
|
50
|
+
yield* rpc.events
|
|
51
|
+
.emit("control", { type: "attached", connectionID: input.connectionID, version: 4 })
|
|
52
|
+
.pipe(Effect.orDie);
|
|
53
|
+
return yield* Deferred.await(browser.closed);
|
|
54
|
+
}).pipe(Effect.scoped),
|
|
55
|
+
state: (input, call) => Effect.gen(function* () {
|
|
56
|
+
const browser = browsers.get(input.sessionID);
|
|
57
|
+
if (!browser || browser.connectionID !== input.connectionID)
|
|
58
|
+
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}));
|
|
59
|
+
browser.state = input.state;
|
|
60
|
+
}),
|
|
61
|
+
command: (input, call) => Effect.gen(function* () {
|
|
62
|
+
const browser = browsers.get(input.sessionID);
|
|
63
|
+
const pending = browser?.connectionID === input.connectionID ? browser.pending.get(input.requestID) : undefined;
|
|
64
|
+
if (!pending)
|
|
65
|
+
return yield* Effect.fail(call.error("unavailable", "Browser request is no longer available.", {}));
|
|
66
|
+
return pending.command;
|
|
67
|
+
}),
|
|
68
|
+
result: (input, call) => Effect.gen(function* () {
|
|
69
|
+
const browser = browsers.get(input.sessionID);
|
|
70
|
+
if (!browser || browser.connectionID !== input.connectionID)
|
|
71
|
+
return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}));
|
|
72
|
+
const pending = browser.pending.get(input.requestID);
|
|
73
|
+
if (!pending)
|
|
74
|
+
return;
|
|
75
|
+
if (input.outcome.type === "failure")
|
|
76
|
+
return yield* Deferred.fail(pending.result, new Tool.Error({ message: `[browser.${input.outcome.code}] ${input.outcome.message}` })).pipe(Effect.asVoid);
|
|
77
|
+
yield* Deferred.succeed(pending.result, input.outcome.result);
|
|
78
|
+
}).pipe(Effect.asVoid),
|
|
79
|
+
"tunnel.open": (input, call) => tunnels(input).pipe(Effect.flatMap((network) => network.open(input.target)), Effect.mapError((error) => call.error("unavailable", error.message, {}))),
|
|
80
|
+
"tunnel.read": (input, call) => tunnels(input).pipe(Effect.flatMap((network) => network.read(input.tunnelID)), Effect.mapError((error) => call.error("unavailable", error.message, {}))),
|
|
81
|
+
"tunnel.write": (input, call) => tunnels(input).pipe(Effect.flatMap((network) => network.write(input.tunnelID, input.data, input.end)), Effect.mapError((error) => call.error("unavailable", error.message, {}))),
|
|
82
|
+
"tunnel.close": (input, call) => tunnels(input).pipe(Effect.flatMap((network) => network.close(input.tunnelID)), Effect.mapError((error) => call.error("unavailable", error.message, {}))),
|
|
83
|
+
})
|
|
84
|
+
.pipe(Effect.orDie);
|
|
85
|
+
yield* ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "session.deleted" || event.type === "session.moved"), Stream.runForEach((event) => close(event.data.sessionID)), Effect.forkScoped({ startImmediately: true }));
|
|
86
|
+
return {
|
|
87
|
+
target: Effect.fn("BrowserConnection.target")(function* (sessionID, action) {
|
|
88
|
+
const browser = browsers.get(sessionID);
|
|
89
|
+
if (!browser)
|
|
90
|
+
return yield* new Tool.Error({
|
|
91
|
+
message: "[browser.disconnected] No desktop browser is connected to this session. Open this session in the desktop app, enable the experimental browser setting, and wait for it to connect. Then call browser.tabs.list({}). Repeating browser actions while disconnected will not help.",
|
|
92
|
+
});
|
|
93
|
+
const tab = "tabID" in action ? browser.state.tabs.find((tab) => tab.id === action.tabID) : undefined;
|
|
94
|
+
if ("tabID" in action && !tab)
|
|
95
|
+
return yield* new Tool.Error({
|
|
96
|
+
message: "[browser.tab_unavailable] This tab is closed or does not belong to the connected session. Call browser.tabs.list({}) and use an exact returned tabID. If no tabs exist, use browser.tabs.open({}). Never substitute a request ID, file ID, or element ref for tabID.",
|
|
97
|
+
});
|
|
98
|
+
// Keep the selected attachment and document, even while permissions or file IO wait.
|
|
99
|
+
return {
|
|
100
|
+
tab,
|
|
101
|
+
inspect: () => request(rpc, browser, action, tab, [], { inspect: true }).pipe(Effect.flatMap((result) => Schema.decodeUnknownEffect(Browser.Target)(result.value)), Effect.mapError((error) => new Tool.Error({
|
|
102
|
+
message: error instanceof Tool.Error
|
|
103
|
+
? error.message
|
|
104
|
+
: "Browser returned invalid target metadata. Check desktop/plugin versions; no action was authorized.",
|
|
105
|
+
error,
|
|
106
|
+
}))),
|
|
107
|
+
request: (files, target) => request(rpc, browser, action, tab, files, { target }),
|
|
108
|
+
};
|
|
109
|
+
}),
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
const request = Effect.fn("BrowserConnection.request")(function* (rpc, browser, action, tab, files, inspection) {
|
|
113
|
+
const requestID = crypto.randomUUID();
|
|
114
|
+
const pending = yield* Deferred.make();
|
|
115
|
+
const command = (action.type === "files.upload" || action.type === "files.drop") && !inspection.inspect
|
|
116
|
+
? { ...action, paths: files.map((file) => file.name) }
|
|
117
|
+
: action;
|
|
118
|
+
browser.pending.set(requestID, {
|
|
119
|
+
command: { action: command, ...(tab ? { generation: tab.generation } : {}), files, ...inspection },
|
|
120
|
+
result: pending,
|
|
121
|
+
});
|
|
122
|
+
return yield* rpc.events.emit("control", { type: "command", connectionID: browser.connectionID, requestID }).pipe(Effect.mapError((error) => new Tool.Error({
|
|
123
|
+
message: `Could not dispatch browser.${action.type}. Check the desktop connection and call browser.tabs.list({}) before deciding whether to retry.`,
|
|
124
|
+
error,
|
|
125
|
+
})), Effect.andThen(Deferred.await(pending)), Effect.raceFirst(Deferred.await(browser.closed).pipe(Effect.andThen(new Tool.Error({
|
|
126
|
+
message: "[browser.disconnected] Browser connection closed; the action may already have run. Reconnect this session in the desktop app, call browser.tabs.list({}), and inspect the target tab with browser.snapshot({tabID}). Do not repeat clicks, submissions, uploads, or evaluations until their outcome is known.",
|
|
127
|
+
})))), Effect.onInterrupt(() => rpc.events.emit("control", { type: "cancel", connectionID: browser.connectionID, requestID }).pipe(Effect.ignore)), Effect.timeoutOrElse({
|
|
128
|
+
duration: "60 seconds",
|
|
129
|
+
orElse: () => new Tool.Error({
|
|
130
|
+
message: `[browser.timeout] browser.${action.type} did not finish within 60 seconds; its outcome is unknown. Check the desktop connection, call browser.tabs.list({}), and inspect the tab or browser.files.list({tabID}) for completed work. Do not blindly repeat a mutating action or start another recording.`,
|
|
131
|
+
}),
|
|
132
|
+
}), Effect.ensuring(Effect.sync(() => browser.pending.delete(requestID))));
|
|
133
|
+
});
|
package/dist/files.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export * as BrowserFiles from "./files.js";
|
|
2
|
+
import { Browser } from "./rpc.js";
|
|
3
|
+
import { Tool } from "@opencode/schema/tool";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
export declare const read: (paths: readonly string[], directory: string) => Effect.Effect<{
|
|
6
|
+
id: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
7
|
+
name: string;
|
|
8
|
+
mime: string;
|
|
9
|
+
data: Uint8Array<ArrayBuffer>;
|
|
10
|
+
}[], Tool.Error, never>;
|
|
11
|
+
export declare const save: (files: readonly Browser.File[]) => Effect.Effect<{
|
|
12
|
+
id: string & import("effect/Brand").Brand<"Browser.FileID">;
|
|
13
|
+
name: string;
|
|
14
|
+
mime: string;
|
|
15
|
+
bytes: number;
|
|
16
|
+
path: string;
|
|
17
|
+
}[], Tool.Error, never>;
|
|
18
|
+
export declare function captureName(name: string): string;
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export * as BrowserFiles from "./files.js";
|
|
2
|
+
import { Browser } from "./rpc.js";
|
|
3
|
+
import { Tool } from "@opencode/schema/tool";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
// Files cross machines as bytes. Only this endpoint interprets its local paths.
|
|
6
|
+
export const read = Effect.fn("BrowserFiles.read")((paths, directory) => Effect.tryPromise({
|
|
7
|
+
try: async () => {
|
|
8
|
+
const { open } = await import("node:fs/promises");
|
|
9
|
+
const { resolve, basename, extname } = await import("node:path");
|
|
10
|
+
const files = await Promise.all(paths.map(async (input) => {
|
|
11
|
+
const file = await open(resolve(directory, input), "r");
|
|
12
|
+
try {
|
|
13
|
+
const stat = await file.stat();
|
|
14
|
+
if (!stat.isFile())
|
|
15
|
+
throw new Error("Upload paths must name files, not directories. Select a server-local file.");
|
|
16
|
+
if (stat.size > Browser.MAX_FILE_BYTES)
|
|
17
|
+
throw new Error(`Upload is ${stat.size} bytes; the limit is ${Browser.MAX_FILE_BYTES} bytes (5 MiB). Select a smaller file; do not retry the same upload.`);
|
|
18
|
+
return {
|
|
19
|
+
id: Browser.FileID.make(`file_${crypto.randomUUID()}`),
|
|
20
|
+
name: basename(input),
|
|
21
|
+
mime: types[extname(input).toLowerCase()] ?? "application/octet-stream",
|
|
22
|
+
data: new Uint8Array(await file.readFile()),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await file.close();
|
|
27
|
+
}
|
|
28
|
+
}));
|
|
29
|
+
if (files.reduce((size, file) => size + file.data.byteLength, 0) > Browser.MAX_FILE_BYTES)
|
|
30
|
+
throw new Error("The selected upload files exceed 5 MiB in total. Send fewer or smaller files; splitting them into one batch does not bypass the total limit.");
|
|
31
|
+
return files;
|
|
32
|
+
},
|
|
33
|
+
catch: (error) => failure("read", error),
|
|
34
|
+
}));
|
|
35
|
+
const types = {
|
|
36
|
+
".txt": "text/plain",
|
|
37
|
+
".csv": "text/csv",
|
|
38
|
+
".json": "application/json",
|
|
39
|
+
".html": "text/html",
|
|
40
|
+
".png": "image/png",
|
|
41
|
+
".jpg": "image/jpeg",
|
|
42
|
+
".jpeg": "image/jpeg",
|
|
43
|
+
".webp": "image/webp",
|
|
44
|
+
".gif": "image/gif",
|
|
45
|
+
".svg": "image/svg+xml",
|
|
46
|
+
".pdf": "application/pdf",
|
|
47
|
+
".zip": "application/zip",
|
|
48
|
+
".gz": "application/gzip",
|
|
49
|
+
};
|
|
50
|
+
export const save = Effect.fn("BrowserFiles.save")((files) => Effect.tryPromise({
|
|
51
|
+
try: async () => {
|
|
52
|
+
if (files.length === 0)
|
|
53
|
+
return [];
|
|
54
|
+
if (files.reduce((size, file) => size + file.data.byteLength, 0) > Browser.MAX_FILE_BYTES)
|
|
55
|
+
throw new Error("Capture files exceed the 5 MiB total transfer limit. Use a smaller screenshot, a shorter trace/profile, or a smaller page for heap capture; do not retry the identical capture.");
|
|
56
|
+
const { mkdtemp, mkdir, writeFile } = await import("node:fs/promises");
|
|
57
|
+
const { join } = await import("node:path");
|
|
58
|
+
const { tmpdir } = await import("node:os");
|
|
59
|
+
const directory = await mkdtemp(join(tmpdir(), "opencode-browser-"));
|
|
60
|
+
return Promise.all(files.map(async (file, index) => {
|
|
61
|
+
const name = captureName(file.name);
|
|
62
|
+
await mkdir(join(directory, String(index)));
|
|
63
|
+
const path = join(directory, String(index), name);
|
|
64
|
+
await writeFile(path, file.data, { flag: "wx" });
|
|
65
|
+
return { id: file.id, name: file.name, mime: file.mime, bytes: file.data.byteLength, path };
|
|
66
|
+
}));
|
|
67
|
+
},
|
|
68
|
+
catch: (error) => failure("save", error),
|
|
69
|
+
}));
|
|
70
|
+
// `.`/`..` escape the per-file directory and Windows resolves device names such as CON.txt regardless of directory.
|
|
71
|
+
export function captureName(name) {
|
|
72
|
+
const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(-160);
|
|
73
|
+
if (!sanitized || /^\.{1,2}$/.test(sanitized) || /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(sanitized))
|
|
74
|
+
return "capture";
|
|
75
|
+
return sanitized;
|
|
76
|
+
}
|
|
77
|
+
function failure(operation, error) {
|
|
78
|
+
const detail = error instanceof Error ? error.message.slice(0, 400) : String(error).slice(0, 400);
|
|
79
|
+
const code = error instanceof Error && "code" in error && typeof error.code === "string" && !detail.startsWith(error.code)
|
|
80
|
+
? `${error.code}: `
|
|
81
|
+
: "";
|
|
82
|
+
const recovery = operation === "save"
|
|
83
|
+
? "The browser may have completed the capture, but no server-local export is confirmed. Check free space and write access on the server. Use browser.files.list({tabID}) and browser.files.get({tabID,fileID}) to retrieve an existing completed capture instead of repeating its browser action."
|
|
84
|
+
: "Upload paths are on the server, not the desktop. Check that each path exists, is a file, and is readable on the server; correct paths or select smaller files before retrying.";
|
|
85
|
+
return new Tool.Error({
|
|
86
|
+
message: `Cannot ${operation} browser files on the server. ${recovery} Details: ${code}${detail}`,
|
|
87
|
+
error,
|
|
88
|
+
});
|
|
89
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin/effect";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { BrowserConnection } from "./connection.js";
|
|
4
|
+
import { BrowserTools } from "./tools.js";
|
|
5
|
+
export default Plugin.define({
|
|
6
|
+
id: "opencode.browser",
|
|
7
|
+
effect: (ctx) => Effect.gen(function* () {
|
|
8
|
+
const connection = yield* BrowserConnection.make(ctx);
|
|
9
|
+
yield* BrowserTools.register(ctx, connection);
|
|
10
|
+
}),
|
|
11
|
+
});
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * as BrowserProxy from "./proxy.js";
|
|
2
|
+
import { Browser } from "./rpc.js";
|
|
3
|
+
export type Transport = {
|
|
4
|
+
open(target: Browser.TunnelTarget, signal: AbortSignal): Promise<string>;
|
|
5
|
+
read(id: string, signal: AbortSignal): Promise<Browser.TunnelRead>;
|
|
6
|
+
write(id: string, data: Uint8Array, end: boolean, signal: AbortSignal): Promise<void>;
|
|
7
|
+
close(id: string): Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
export type Proxy = Awaited<ReturnType<typeof make>>;
|
|
10
|
+
export declare function make(transport: Transport): Promise<{
|
|
11
|
+
url: string;
|
|
12
|
+
host: string;
|
|
13
|
+
port: number;
|
|
14
|
+
credentials: {
|
|
15
|
+
username: string;
|
|
16
|
+
password: string;
|
|
17
|
+
};
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}>;
|