mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 01:46:23 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c781f49201 | ||
|
|
f23200b9b0 | ||
|
|
cbd86a8cc9 | ||
|
|
74a635e231 | ||
|
|
f1a9f008b0 | ||
|
|
4c31621a21 | ||
|
|
93e1d1a18e |
@@ -3,6 +3,7 @@ import type { OpenCode } from "./client.js"
|
||||
type Client = ReturnType<typeof OpenCode.make>
|
||||
|
||||
export type { RpcApi, RpcCallOptions, RpcClient, RpcEventPayload } from "./rpc.js"
|
||||
export type { PermissionCreateInput } from "./generated/types.js"
|
||||
|
||||
export type AgentApi = Client["agent"]
|
||||
export type CommandApi = Client["command"]
|
||||
|
||||
@@ -382,6 +382,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
}),
|
||||
},
|
||||
permission: {
|
||||
assert: permission.assert,
|
||||
hook: (name, callback) => hooks.register("permission", name, callback),
|
||||
list: (input) => permission.forSession(input.sessionID),
|
||||
get: (input) =>
|
||||
|
||||
@@ -104,6 +104,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
},
|
||||
permission: overrides.permission ?? {
|
||||
assert: () => Effect.die("unused permission.assert"),
|
||||
hook: () => Effect.die("unused permission.hook"),
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
get: () => Effect.die("unused permission.get"),
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { define } from "@opencode-ai/plugin/promise/plugin"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Queue } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { emptyMcpLayer } from "../fixture/mcp"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Plugin.node, Database.node, Bus.node, Location.node]), [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Config.node.replace(Config.testLayer()),
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const location = yield* Location.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const bus = yield* Bus.Service
|
||||
const asked = yield* Queue.unbounded<void>()
|
||||
const unsubscribe = yield* bus.listen((event) =>
|
||||
event.type === Permission.Event.Asked.type ? Queue.offer(asked, undefined).pipe(Effect.asVoid) : Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const ready = yield* Deferred.make<Context>()
|
||||
yield* plugins.activate([{ id: "permission-test", revision: "1", effect: (ctx) => Deferred.succeed(ready, ctx) }])
|
||||
const ctx = yield* Deferred.await(ready)
|
||||
yield* ctx.agent.transform((draft) =>
|
||||
draft.update("permission-test", (agent) => {
|
||||
agent.permissions = [
|
||||
{ action: "deploy", resource: "*", effect: "ask" },
|
||||
{ action: "deploy", resource: "allowed", effect: "allow" },
|
||||
{ action: "deploy", resource: "blocked", effect: "deny" },
|
||||
]
|
||||
}),
|
||||
)
|
||||
const sessionID = Session.ID.create()
|
||||
yield* database.db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: location.project.id, worktree: location.directory, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
yield* database.db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: location.project.id,
|
||||
slug: "permission-test",
|
||||
directory: location.directory,
|
||||
title: "Permission test",
|
||||
version: "test",
|
||||
agent: "missing",
|
||||
})
|
||||
.run()
|
||||
const input = {
|
||||
id: Permission.ID.create(),
|
||||
sessionID,
|
||||
agent: Agent.ID.make("permission-test"),
|
||||
action: "deploy",
|
||||
resources: ["staging"],
|
||||
save: ["staging"],
|
||||
metadata: { environment: "staging" },
|
||||
source: { type: "tool", messageID: "msg_test", id: "call_test" },
|
||||
} satisfies Permission.AssertInput
|
||||
return { ctx, input, asked }
|
||||
})
|
||||
|
||||
describe("plugin permission.assert", () => {
|
||||
it.live("preserves Effect decisions, rejection defects, feedback, and cancellation cleanup", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx, input, asked } = yield* setup
|
||||
expect(yield* ctx.permission.assert({ ...input, resources: ["allowed"] })).toBeUndefined()
|
||||
expect(yield* ctx.permission.assert({ ...input, resources: ["blocked"] }).pipe(Effect.flip)).toBeInstanceOf(
|
||||
Permission.BlockedError,
|
||||
)
|
||||
expect(yield* ctx.permission.list(input)).toEqual([])
|
||||
|
||||
yield* Effect.forEach(["once", "reject", "feedback", "cancel"] as const, (reply) =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* ctx.permission.assert(input).pipe(Effect.forkScoped)
|
||||
yield* Queue.take(asked)
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
expect(yield* ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
|
||||
id: input.id,
|
||||
sessionID: input.sessionID,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
save: input.save,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
})
|
||||
if (reply === "cancel") yield* Fiber.interrupt(fiber)
|
||||
if (reply !== "cancel")
|
||||
yield* ctx.permission.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.id,
|
||||
reply: reply === "feedback" ? "reject" : reply,
|
||||
message: reply === "feedback" ? "Use the test environment" : undefined,
|
||||
})
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
if (reply === "once") expect(exit).toEqual(Exit.succeed(undefined))
|
||||
if (reply !== "once") {
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
if (reply === "cancel") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
if (reply === "reject")
|
||||
expect(exit.cause.reasons).toContainEqual(
|
||||
expect.objectContaining({ _tag: "Die", defect: expect.any(Permission.DeclinedError) }),
|
||||
)
|
||||
if (reply === "feedback")
|
||||
expect(exit.cause.reasons).toContainEqual(
|
||||
expect.objectContaining({
|
||||
_tag: "Fail",
|
||||
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
expect(yield* ctx.permission.list(input)).toEqual([])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("decodes Promise inputs and preserves void results and permission errors through the real host", () =>
|
||||
Effect.gen(function* () {
|
||||
const { ctx, input, asked } = yield* setup
|
||||
yield* PluginPromise.fromPromise(
|
||||
define({
|
||||
id: "promise-permission-test",
|
||||
setup: async (ctx) => {
|
||||
await expect(
|
||||
Reflect.apply(ctx.permission.assert, undefined, [{ ...input, resources: [42] }]),
|
||||
).rejects.toBeDefined()
|
||||
expect(await ctx.permission.list(input)).toEqual([])
|
||||
expect(await ctx.permission.assert({ ...input, id: null, resources: ["allowed"] })).toBeUndefined()
|
||||
await expect(ctx.permission.assert({ ...input, resources: ["blocked"] })).rejects.toBeInstanceOf(
|
||||
Permission.BlockedError,
|
||||
)
|
||||
|
||||
for (const reply of ["once", "reject", "feedback"] as const) {
|
||||
const pending = ctx.permission.assert(input)
|
||||
const settled = pending.then(
|
||||
(value) => ({ value }),
|
||||
(error: unknown) => ({ error }),
|
||||
)
|
||||
await Effect.runPromise(Queue.take(asked))
|
||||
expect(await ctx.permission.get({ sessionID: input.sessionID, requestID: input.id })).toMatchObject({
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
save: input.save,
|
||||
})
|
||||
await ctx.permission.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.id,
|
||||
reply: reply === "feedback" ? "reject" : reply,
|
||||
...(reply === "feedback" ? { message: "Use the test environment" } : {}),
|
||||
})
|
||||
if (reply === "once") expect(await settled).toEqual({ value: undefined })
|
||||
if (reply === "reject") expect(await settled).toEqual({ error: expect.any(Permission.DeclinedError) })
|
||||
if (reply === "feedback")
|
||||
expect(await settled).toEqual({
|
||||
error: new Permission.CorrectedError({ feedback: "Use the test environment" }),
|
||||
})
|
||||
expect(await ctx.permission.list(input)).toEqual([])
|
||||
}
|
||||
},
|
||||
}),
|
||||
).effect(ctx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -14,6 +14,7 @@ export const Smoke = Rpc.define({
|
||||
output: Schema.String,
|
||||
},
|
||||
read: { input: Schema.Struct({ path: Schema.String }), output: Schema.String },
|
||||
deny: { input: Schema.Struct({ urls: Schema.Array(Schema.String) }), output: Schema.Void },
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
@@ -396,6 +396,11 @@ async function main() {
|
||||
.find((line) => line.includes('[button] "Frame button"'))
|
||||
?.match(/@e\d+/)?.[0]
|
||||
assert(childRef, childSnapshot.content)
|
||||
await rpc.deny({ urls: [child.url] }, { location })
|
||||
await fails("evaluate", { tabID, frameID: child.id, script: "document.body.innerText" }, /permission_denied/)
|
||||
await fails("snapshot", { tabID, frameID: child.id }, /permission_denied/)
|
||||
await fails("click", { tabID, ref: childRef }, /permission_denied/)
|
||||
await rpc.deny({ urls: [] }, { location })
|
||||
await call("click", { tabID, ref: Browser.Ref.make(childRef) })
|
||||
assert.equal(
|
||||
(await call("evaluate", { tabID, frameID: child.id, script: "document.querySelector('button').textContent" }))
|
||||
@@ -446,6 +451,9 @@ async function main() {
|
||||
assert(network.requests.length)
|
||||
const detail = await call("network.get", { tabID, id: network.requests[0].id, includeBody: true })
|
||||
assert.equal(detail.responseBody.state, "text")
|
||||
await rpc.deny({ urls: [network.requests[0].url] }, { location })
|
||||
await fails("network.get", { tabID, id: network.requests[0].id, includeBody: true }, /permission_denied/)
|
||||
await rpc.deny({ urls: [] }, { location })
|
||||
await fails("network.get", { tabID, id: "unknown-request" }, /Do not reload or resend/)
|
||||
const fileSnap = await call("snapshot", { tabID })
|
||||
const input = fileSnap.content
|
||||
@@ -498,6 +506,12 @@ async function main() {
|
||||
Buffer.from(await rpc.read({ path: download.files[0].path }, { location }), "base64").toString(),
|
||||
"desktop download bytes",
|
||||
)
|
||||
await call("navigate", { tabID, url: "about:blank" })
|
||||
await rpc.deny({ urls: [fixture + "/download"] }, { location })
|
||||
await fails("files.get", { tabID, fileID: downloads.id }, /permission_denied/)
|
||||
await fails("heap.summary", { tabID, fileID: downloads.id }, /permission_denied/)
|
||||
await rpc.deny({ urls: [] }, { location })
|
||||
await call("navigate", { tabID, url: fixture })
|
||||
await call("evaluate", { tabID, script: "setTimeout(()=>alert('hello dialog'),0); null" })
|
||||
await until(async () => (await call("dialog", { tabID, action: "get" })).dialog)
|
||||
await fails("evaluate", { tabID, script: "1" }, /Inspect it with browser\.dialog/)
|
||||
|
||||
@@ -13,6 +13,14 @@ export default Plugin.define({
|
||||
id: "browser.smoke",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const denied = new Set<string>()
|
||||
yield* ctx.permission
|
||||
.hook("evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.action === "browser" && event.resources.some((url) => denied.has(url))) event.effect = "deny"
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const tools = new Map<string, Tool.Info>()
|
||||
yield* ctx.tool
|
||||
.transform((editor) => {
|
||||
@@ -24,6 +32,11 @@ export default Plugin.define({
|
||||
.pipe(Effect.orDie)
|
||||
yield* ctx.rpc
|
||||
.register(Smoke, {
|
||||
deny: ({ urls }) =>
|
||||
Effect.sync(() => {
|
||||
denied.clear()
|
||||
urls.forEach((url) => denied.add(url))
|
||||
}),
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* CodeModeTool.create({ tools }, (name, tool, input, context) =>
|
||||
|
||||
@@ -118,8 +118,16 @@ File errors distinguish server-local upload paths from desktop capture files.
|
||||
Pending/failed downloads and unavailable response bodies are not empty files.
|
||||
Oversized output requires a smaller request or capture, not an identical retry.
|
||||
|
||||
Per-URL and server-file permission checks belong to the final permission layer
|
||||
(#46530). This base plugin layer intentionally does not enforce those rules.
|
||||
Browser actions inspect target metadata before using the existing permission
|
||||
engine. Explicit frame/ref operations authorize their actual frame URLs, network
|
||||
details authorize the retained request URL before reading bodies, and retained
|
||||
files authorize their original source URLs even after navigation. The desktop
|
||||
rejects changed targets rather than executing an approval against new content.
|
||||
Uploads
|
||||
also check server-file read permissions and external-directory access against
|
||||
resolved paths. Network details check the request URL before returning data.
|
||||
These are action/disclosure checks, not a firewall for every page subresource
|
||||
or redirect. Browser storage and networking remain on the desktop.
|
||||
|
||||
Disable through normal configuration:
|
||||
|
||||
|
||||
@@ -4,6 +4,25 @@ import { Browser } from "./rpc.js"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const resolve = Effect.fn("BrowserFiles.resolve")((inputs: readonly string[], directory: string) =>
|
||||
Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { realpath } = await import("node:fs/promises")
|
||||
const { resolve, relative, isAbsolute, sep } = await import("node:path")
|
||||
const root = await realpath(directory)
|
||||
const paths = await Promise.all(inputs.map((file) => realpath(resolve(directory, file))))
|
||||
return {
|
||||
paths,
|
||||
external: paths.filter((file) => {
|
||||
const value = relative(root, file)
|
||||
return value === ".." || value.startsWith(`..${sep}`) || isAbsolute(value)
|
||||
}),
|
||||
}
|
||||
},
|
||||
catch: (error) => failure("resolve", error),
|
||||
}),
|
||||
)
|
||||
|
||||
// Files cross machines as bytes. Only this endpoint interprets its local paths.
|
||||
export const read = Effect.fn("BrowserFiles.read")((paths: readonly string[], directory: string) =>
|
||||
Effect.tryPromise({
|
||||
@@ -92,7 +111,7 @@ export function captureName(name: string) {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
function failure(operation: "read" | "save", error: unknown) {
|
||||
function failure(operation: "resolve" | "read" | "save", error: unknown) {
|
||||
const detail = error instanceof Error ? error.message.slice(0, 400) : String(error).slice(0, 400)
|
||||
const code =
|
||||
error instanceof Error && "code" in error && typeof error.code === "string" && !detail.startsWith(error.code)
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BrowserFiles } from "./files.js"
|
||||
import { Browser } from "./rpc.js"
|
||||
|
||||
export const register = Effect.fn("BrowserTools.register")(function* (
|
||||
ctx: Pick<Context, "tool" | "location">,
|
||||
ctx: Pick<Context, "tool" | "location" | "permission">,
|
||||
connection: BrowserConnection.Connection,
|
||||
) {
|
||||
const execute = Effect.fn("BrowserTools.execute")(function* (
|
||||
@@ -21,12 +21,21 @@ export const register = Effect.fn("BrowserTools.register")(function* (
|
||||
catch: (error) => new Tool.Error({ message: invalidURL, error }),
|
||||
})
|
||||
const target = yield* connection.target(tool.sessionID, action)
|
||||
const uploads =
|
||||
action.type === "files.upload" || action.type === "files.drop"
|
||||
? yield* BrowserFiles.read(action.paths, ctx.location.directory)
|
||||
: []
|
||||
const response = yield* target.request(uploads)
|
||||
const authorize = permissionCheck(ctx.permission, action, target.tab, tool)
|
||||
const url = action.type === "navigate" || action.type === "tabs.open" ? action.url : target.tab?.url
|
||||
const inspected = target.tab && !action.type.startsWith("tabs.") ? yield* target.inspect() : undefined
|
||||
const resources = inspected?.resources ?? (url ? [url] : [])
|
||||
if (resources.length && !(action.type === "tabs.open" && url === "about:blank"))
|
||||
yield* authorize("browser", resources)
|
||||
const uploads = yield* prepareUploads(action, ctx.location.directory, authorize)
|
||||
const response = yield* target.request(uploads, inspected)
|
||||
const output = yield* Effect.fromResult(decodeResult(operation, response))
|
||||
if (action.type === "network.get" && "request" in output && !resources.includes(output.request.url))
|
||||
yield* authorize("browser", [output.request.url])
|
||||
if (response.files.length && !("files" in output))
|
||||
return yield* new Tool.Error({
|
||||
message: `Browser returned unexpected files for browser.${operation.name}; no files were exported. Check desktop/server plugin compatibility and report the invalid response. Do not repeat the action to repair a protocol error.`,
|
||||
})
|
||||
return yield* exportResult(output, response.files)
|
||||
})
|
||||
|
||||
@@ -57,6 +66,49 @@ export const register = Effect.fn("BrowserTools.register")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
function permissionCheck(
|
||||
permission: Context["permission"],
|
||||
action: Browser.Action,
|
||||
tab: Browser.Tab | undefined,
|
||||
tool: Tool.Context,
|
||||
) {
|
||||
return (name: string, resources: readonly string[]) =>
|
||||
permission
|
||||
.assert({
|
||||
action: name,
|
||||
resources,
|
||||
sessionID: tool.sessionID,
|
||||
agent: tool.agent,
|
||||
metadata: { type: action.type, ...(tab ? { tabID: tab.id } : {}) },
|
||||
source: { type: "tool", messageID: tool.messageID, id: tool.id },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) => {
|
||||
const feedback = Schema.decodeUnknownOption(Schema.Struct({ feedback: Schema.String }))(error)
|
||||
const detail =
|
||||
feedback._tag === "Some"
|
||||
? `User feedback: ${feedback.value.feedback}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
return new Tool.Error({
|
||||
message: `[browser.permission_denied] Permission "${name}" was not granted for browser.${action.type}. Do not retry through another tool or change the target to bypass this decision. Follow the user's feedback or ask for an approved action. ${detail}`,
|
||||
error,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function prepareUploads(action: Browser.Action, directory: string, authorize: ReturnType<typeof permissionCheck>) {
|
||||
return Effect.gen(function* () {
|
||||
if (action.type !== "files.upload" && action.type !== "files.drop") return []
|
||||
const resolved = yield* BrowserFiles.resolve(action.paths, directory)
|
||||
if (resolved.external.length) yield* authorize("external_directory", resolved.external)
|
||||
yield* authorize("read", resolved.paths)
|
||||
return yield* BrowserFiles.read(resolved.paths, directory)
|
||||
})
|
||||
}
|
||||
|
||||
function decodeResult(operation: Browser.Operation, result: Browser.Result) {
|
||||
return Result.gen(function* () {
|
||||
const value = result.files.length
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { PermissionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { PermissionApi, PermissionCreateInput } from "@opencode-ai/client/effect/api"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Permission } from "@opencode-ai/schema/permission"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import type { Effect } from "effect"
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface PermissionEvaluation {
|
||||
@@ -20,5 +21,6 @@ export interface PermissionHooks {
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
|
||||
readonly assert: (input: PermissionCreateInput) => Effect.Effect<void, unknown>
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
|
||||
@@ -259,13 +259,14 @@ export function fromPromise(plugin: Plugin) {
|
||||
const adaptApiMethod = <PromiseMethod>(
|
||||
endpoint: HttpApiEndpoint.Top,
|
||||
method: (input: never) => Effect.Effect<unknown, unknown>,
|
||||
options?: { readonly noContent?: boolean },
|
||||
) => {
|
||||
const compiled = compileEndpoint(endpoint)
|
||||
return ((input?: unknown) =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* Effect.forEach(compiled.decode, (decode) => decode(input ?? {}))
|
||||
const result = yield* method(Object.assign({}, ...decoded) as never)
|
||||
if (compiled.noContent) return undefined
|
||||
if (compiled.noContent || options?.noContent) return undefined
|
||||
return yield* compiled.encode(result)
|
||||
}).pipe(Effect.runPromiseWith(context))) as PromiseMethod
|
||||
}
|
||||
@@ -428,6 +429,9 @@ export function fromPromise(plugin: Plugin) {
|
||||
reload: () => run(host.mcp.reload()),
|
||||
},
|
||||
permission: {
|
||||
assert: adaptApiMethod(PermissionEndpoints["session.permission.create"], host.permission.assert, {
|
||||
noContent: true,
|
||||
}),
|
||||
hook: (name, callback) =>
|
||||
register(host.permission.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PermissionApi } from "@opencode-ai/client/promise/api"
|
||||
import type { PermissionApi, PermissionCreateInput } from "@opencode-ai/client/promise/api"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Permission } from "@opencode-ai/schema/permission"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
@@ -20,5 +20,6 @@ export interface PermissionHooks {
|
||||
}
|
||||
|
||||
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
|
||||
readonly assert: (input: PermissionCreateInput) => Promise<void>
|
||||
readonly hook: Hooks<PermissionHooks>
|
||||
}
|
||||
|
||||
@@ -648,6 +648,22 @@ const review = await ctx.generate.text({
|
||||
|
||||
### Permissions
|
||||
|
||||
Assert permission before a plugin action. This plugin-only method uses the generated client's `PermissionCreateInput`
|
||||
and returns no value: `allow` completes immediately, `ask` waits for a reply, and `deny` fails.
|
||||
|
||||
```ts
|
||||
await ctx.permission.assert({
|
||||
sessionID,
|
||||
action: "deploy",
|
||||
resources: ["staging"],
|
||||
save: ["staging"],
|
||||
})
|
||||
```
|
||||
|
||||
Promise plugins receive `Promise<void>`. Effect plugins receive `Effect<void, unknown>` and run it with
|
||||
`yield* ctx.permission.assert(input)`. Let a rejection propagate so the action does not run. A rejection without feedback
|
||||
preserves the permission engine's session-interruption behavior; a rejection with feedback carries `Permission.CorrectedError`.
|
||||
|
||||
Inspect or resolve pending permission requests.
|
||||
|
||||
```ts
|
||||
|
||||
Reference in New Issue
Block a user