fix(browser): expose target inspection and attachment replacement

This commit is contained in:
LukeParkerDev 2026-09-04 09:28:38 +10:00
parent 8ffaeec306
commit c7bd568edb
5 changed files with 53 additions and 17 deletions

View file

@ -40,7 +40,7 @@ pure and does not load any of these runtime modules.
The plugin-owned contract is `@opencode-ai/plugin-browser/rpc`. This entrypoint
contains only schemas and descriptions; it does not load the server plugin or
filesystem code. The desktop subscribes
to control events before starting `attach` with `version: 2`. The attachment call
to control events before starting `attach` with `version: 3`. The attachment call
stays pending for its lifetime. A matching `attached` event is the readiness barrier.
- `state` publishes the authoritative tab inventory.
@ -48,6 +48,10 @@ stays pending for its lifetime. A matching `attached` event is the readiness bar
script source, file bytes, or browser results on the server-wide event feed.
- `command` retrieves the pending request through authenticated RPC.
- `result` completes it. The plugin validates the selected operation's output.
- Inspection commands return only target/source metadata. Execution checks that
the approved target has not changed while permission was pending.
- `attach` returns `replaced` when another desktop takes ownership. That is not
a retryable disconnect; the old desktop must not reclaim the session automatically.
The connection ID is correlation, not separate client authentication. Requests
are bound to their attachment and tab. Disconnect, replacement, session movement,

View file

@ -4,13 +4,13 @@ import type { Context } from "@opencode-ai/plugin/effect/plugin"
import type { RpcRegistration } from "@opencode-ai/plugin/effect/rpc"
import type { Session } from "@opencode-ai/schema/session"
import { Tool } from "@opencode-ai/schema/tool"
import { Deferred, Effect, Stream } from "effect"
import { Deferred, Effect, Schema, Stream } from "effect"
import { Browser } from "./rpc.js"
type Attachment = {
connectionID: string
state: Browser.State
closed: Deferred.Deferred<void>
closed: Deferred.Deferred<"closed" | "replaced">
pending: Map<string, { command: Browser.Command; result: Deferred.Deferred<Browser.Result, Tool.Error> }>
}
@ -21,16 +21,16 @@ export const make = Effect.fn("BrowserConnection.make")(function* (
) {
const browsers = new Map<Session.ID, Attachment>()
let active = true
const close = (sessionID: Session.ID) =>
const close = (sessionID: Session.ID, reason: "closed" | "replaced" = "closed") =>
Effect.gen(function* () {
const browser = browsers.get(sessionID)
if (!browser) return
browsers.delete(sessionID)
yield* Deferred.succeed(browser.closed, undefined)
yield* Deferred.succeed(browser.closed, reason)
})
yield* Effect.addFinalizer(() => {
active = false
return Effect.forEach(browsers.keys(), close, { discard: true })
return Effect.forEach(browsers.keys(), (id) => close(id), { discard: true })
})
const rpc: RpcRegistration<typeof Browser.Definition> = yield* ctx.rpc
.register(Browser.Definition, {
@ -47,11 +47,11 @@ export const make = Effect.fn("BrowserConnection.make")(function* (
const browser = yield* Effect.acquireRelease(
Effect.gen(function* () {
if (!active) return yield* Effect.fail(call.error("unavailable", "Browser is unavailable.", {}))
yield* close(input.sessionID)
yield* close(input.sessionID, "replaced")
const browser: Attachment = {
connectionID: input.connectionID,
state: { tabs: [], focusedTabID: null },
closed: yield* Deferred.make<void>(),
closed: yield* Deferred.make<"closed" | "replaced">(),
pending: new Map(),
}
browsers.set(input.sessionID, browser)
@ -60,9 +60,9 @@ export const make = Effect.fn("BrowserConnection.make")(function* (
(browser) => (browsers.get(input.sessionID) === browser ? close(input.sessionID) : Effect.void),
)
yield* rpc.events
.emit("control", { type: "attached", connectionID: input.connectionID, version: 2 })
.emit("control", { type: "attached", connectionID: input.connectionID, version: 3 })
.pipe(Effect.orDie)
yield* Deferred.await(browser.closed)
return yield* Deferred.await(browser.closed)
}).pipe(Effect.scoped),
state: (input, call) =>
Effect.gen(function* () {
@ -117,7 +117,25 @@ export const make = Effect.fn("BrowserConnection.make")(function* (
"[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.",
})
// Keep the selected attachment and document, even while permissions or file IO wait.
return { tab, request: (files: readonly Browser.File[]) => request(rpc, browser, action, tab, files) }
return {
tab,
inspect: () =>
request(rpc, browser, action, tab, [], { inspect: true }).pipe(
Effect.flatMap((result) => Schema.decodeUnknownEffect(Browser.Target)(result.value)),
Effect.mapError(
(error) =>
new Tool.Error({
message:
error instanceof Tool.Error
? error.message
: "Browser returned invalid target metadata. Check desktop/plugin versions; no action was authorized.",
error,
}),
),
),
request: (files: readonly Browser.File[], target?: Browser.Target) =>
request(rpc, browser, action, tab, files, { target }),
}
}),
}
})
@ -128,6 +146,7 @@ const request = Effect.fn("BrowserConnection.request")(function* (
action: Browser.Action,
tab: Browser.Tab | undefined,
files: readonly Browser.File[],
inspection: Pick<Browser.Command, "inspect" | "target">,
) {
const requestID = crypto.randomUUID()
const pending = yield* Deferred.make<Browser.Result, Tool.Error>()
@ -136,7 +155,7 @@ const request = Effect.fn("BrowserConnection.request")(function* (
? { ...action, paths: files.map((file) => file.name) }
: action
browser.pending.set(requestID, {
command: { action: command, ...(tab ? { generation: tab.generation } : {}), files },
command: { action: command, ...(tab ? { generation: tab.generation } : {}), files, ...inspection },
result: pending,
})
return yield* rpc.events.emit("control", { type: "command", connectionID: browser.connectionID, requestID }).pipe(

View file

@ -476,10 +476,15 @@ export const Action = Schema.Union(Operations.map((operation) => operation.actio
identifier: "Browser.Action",
})
export type Action = typeof Action.Type
// Metadata only: never page content, headers, bodies, or file bytes.
export const Target = Schema.Struct({ resources: Schema.Array(text), key: text })
export type Target = typeof Target.Type
export const Command = Schema.Struct({
action: Action,
generation: optional(count),
files: Schema.Array(File),
inspect: optional(Schema.Boolean),
target: optional(Target),
}).annotate({ identifier: "Browser.Command" })
export interface Command extends Schema.Schema.Type<typeof Command> {}
export const Result = Schema.Struct({ value: Schema.Json, files: Schema.Array(File) }).annotate({
@ -497,7 +502,7 @@ const attachment = { sessionID: Session.ID, connectionID: Schema.String }
const request = { ...attachment, requestID: Schema.String }
const errors = { unavailable: Schema.Struct({}) }
export const Control = Schema.Union([
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String, version: Schema.Literal(2) }),
Schema.Struct({ type: Schema.Literal("attached"), connectionID: Schema.String, version: Schema.Literal(3) }),
Schema.Struct({
type: Schema.Literal("command"),
connectionID: Schema.String,
@ -511,7 +516,11 @@ export type Control = typeof Control.Type
export const Definition = Rpc.define({
id: "experimental.browser",
methods: {
attach: { input: Schema.Struct({ ...attachment, version: Schema.Literal(2) }), output: Schema.Void, errors },
attach: {
input: Schema.Struct({ ...attachment, version: Schema.Literal(3) }),
output: Schema.Literals(["closed", "replaced"]),
errors,
},
state: { input: Schema.Struct({ ...attachment, state: State }), output: Schema.Void, errors },
command: { input: Schema.Struct(request), output: Command, errors },
result: { input: Schema.Struct({ ...request, outcome: Outcome }), output: Schema.Void, errors },

View file

@ -46,4 +46,8 @@ test("network lifecycle and RPC version are explicit", () => {
expect(decode({ ...request, state: "completed", statusCode: 404, durationMs: 3 }).state).toBe("completed")
expect(() => decode({ ...request, state: "failed" })).toThrow()
expect(() => Schema.decodeUnknownSync(Browser.Control)({ type: "attached", connectionID: "old-client" })).toThrow()
expect(() =>
Schema.decodeUnknownSync(Browser.Control)({ type: "attached", connectionID: "old-client", version: 2 }),
).toThrow()
expect(Schema.decodeUnknownSync(Browser.Definition.methods.attach.output)("replaced")).toBe("replaced")
})

View file

@ -95,8 +95,8 @@ const fixture = Effect.gen(function* () {
context,
attach: Effect.fn(function* (connectionID: string) {
const input = { sessionID: session.id, connectionID }
const lifetime = yield* rpc.attach({ ...input, version: 2 }, { location }).pipe(Effect.forkScoped)
expect((yield* next).data).toEqual({ type: "attached", connectionID, version: 2 })
const lifetime = yield* rpc.attach({ ...input, version: 3 }, { location }).pipe(Effect.forkScoped)
expect((yield* next).data).toEqual({ type: "attached", connectionID, version: 3 })
return { input, lifetime }
}),
command: Effect.fn(function* (action: Browser.Action) {
@ -124,7 +124,7 @@ test(
})
const old = yield* host.attach("old")
const attached = yield* host.attach("current")
yield* Fiber.join(old.lifetime)
expect(yield* Fiber.join(old.lifetime)).toBe("replaced")
expect(yield* host.rpc.state({ ...old.input, state }, options).pipe(Effect.flip)).toMatchObject({
type: "unavailable",
})