mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 17:44:45 +00:00
fix(browser): expose target inspection and attachment replacement
This commit is contained in:
parent
8ffaeec306
commit
c7bd568edb
5 changed files with 53 additions and 17 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue