Compare commits

...
Author SHA1 Message Date
LukeParkerDev e578ccf940 feat(plugin): expose permission assertions 2026-09-01 13:26:00 +10:00
8 changed files with 218 additions and 3 deletions
+1
View File
@@ -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"]
+1
View File
@@ -326,6 +326,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
}),
},
permission: {
assert: permission.assert,
hook: (name, callback) => hooks.register("permission", name, callback),
list: (input) => permission.forSession(input.sessionID),
get: (input) =>
+1
View File
@@ -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,189 @@
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 { 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(
LayerNode.compile(LayerNode.group([Plugin.node, Database.node, Bus.node, Location.node]), {
replacements: [
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", version: "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)
}),
)
})
+3 -1
View File
@@ -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>
}
+5 -1
View File
@@ -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),
+2 -1
View File
@@ -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>
}
@@ -636,6 +636,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