Compare commits

...
Author SHA1 Message Date
Kit Langton ea992bb5bc refactor(core): split permission policy from global ledger 2026-09-01 21:27:56 -04:00
14 changed files with 624 additions and 368 deletions
+2 -1
View File
@@ -180,7 +180,8 @@ const table = sqliteTable("session", {
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Keep `SessionRunner`, model resolution, tool registry, permission policy, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Config-derived state stays in the instance; transient state keyed by a Session or that clients read host-wide goes in a global node so reads never boot an instance. `PermissionPolicy` evaluates in the instance; `PermissionLedger` holds pending requests process-wide, publishes their events with the asking instance's `location`, and the fiber awaiting a request owns its entry: interrupting it cancels the request and emits `permission.replied` with `reject`.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
@@ -62,7 +62,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
input.sdk.api.permission.request
.list({ location: { directory: location.directory, workspace: location.workspaceID } })
.then((pending) => {
if (!state.disposed) pending.data.forEach((request) => approve(request))
if (!state.disposed) pending.forEach((request) => approve(request))
return true
})
.catch(() => false),
+10 -13
View File
@@ -26,8 +26,8 @@ import type { Integration } from "@opencode-ai/schema/integration"
import type { Form } from "@opencode-ai/schema/form"
import type { Mcp } from "@opencode-ai/schema/mcp"
import type { Credential } from "@opencode-ai/schema/credential"
import type { Permission } from "@opencode-ai/schema/permission"
import type { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import type { Permission } from "@opencode-ai/schema/permission"
import type { FileSystem } from "@opencode-ai/schema/filesystem"
import type { Command } from "@opencode-ai/schema/command"
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
@@ -1482,17 +1482,6 @@ export interface FormApi<E = never> {
readonly cancel: FormCancelOperation<E>
}
export type PermissionRequestListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type PermissionRequestListOutput = {
readonly location: Location.Info
readonly data: ReadonlyArray<Permission.Request>
}
export type PermissionRequestListOperation<E = never> = (
input?: PermissionRequestListInput,
) => Effect.Effect<PermissionRequestListOutput, E>
export type PermissionSavedListInput = { readonly projectID?: Project.ID | undefined }
export type PermissionSavedListOutput = ReadonlyArray<PermissionSaved.Info>
export type PermissionSavedListOperation<E = never> = (
@@ -1505,6 +1494,14 @@ export type PermissionSavedRemoveOperation<E = never> = (
input: PermissionSavedRemoveInput,
) => Effect.Effect<PermissionSavedRemoveOutput, E>
export type PermissionRequestListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type PermissionRequestListOutput = ReadonlyArray<Permission.Request>
export type PermissionRequestListOperation<E = never> = (
input?: PermissionRequestListInput,
) => Effect.Effect<PermissionRequestListOutput, E>
export type PermissionCreateInput = {
readonly sessionID: Session.ID
readonly id?: Permission.ID | undefined
@@ -1540,8 +1537,8 @@ export type PermissionReplyOperation<E = never> = (
) => Effect.Effect<PermissionReplyOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly create: PermissionCreateOperation<E>
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
+11 -8
View File
@@ -167,12 +167,12 @@ import type {
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
PermissionSavedListOutput,
PermissionSavedRemoveInput,
PermissionSavedRemoveOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionCreateInput,
PermissionCreateOutput,
PermissionListInput,
@@ -1090,11 +1090,6 @@ const adaptGroupForm = (raw: RawClient["server.form"]) => ({
cancel: EndpointFormCancel(raw),
})
const EndpointPermissionRequestList = (raw: RawClient["server.permission"]) => (input?: PermissionRequestListInput) =>
preserveEffect<PermissionRequestListOutput>()(
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionSavedList = (raw: RawClient["server.permission"]) => (input?: PermissionSavedListInput) =>
preserveEffect<PermissionSavedListOutput>()(
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
@@ -1108,6 +1103,14 @@ const EndpointPermissionSavedRemove = (raw: RawClient["server.permission"]) => (
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRequestList = (raw: RawClient["server.permission"]) => (input?: PermissionRequestListInput) =>
preserveEffect<PermissionRequestListOutput>()(
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointPermissionCreate = (raw: RawClient["server.permission"]) => (input: PermissionCreateInput) =>
preserveEffect<PermissionCreateOutput>()(
raw["session.permission.create"]({
@@ -1152,8 +1155,8 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
)
const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
request: { list: EndpointPermissionRequestList(raw) },
saved: { list: EndpointPermissionSavedList(raw), remove: EndpointPermissionSavedRemove(raw) },
request: { list: EndpointPermissionRequestList(raw) },
create: EndpointPermissionCreate(raw),
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
+18 -18
View File
@@ -161,12 +161,12 @@ import type {
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
PermissionSavedListOutput,
PermissionSavedRemoveInput,
PermissionSavedRemoveOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionCreateInput,
PermissionCreateOutput,
PermissionListInput,
@@ -1465,20 +1465,6 @@ export function make(options: ClientOptions) {
),
},
permission: {
request: {
list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) =>
request<PermissionRequestListOutput>(
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
saved: {
list: (input?: PermissionSavedListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionSavedListOutput }>(
@@ -1504,6 +1490,20 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
request: {
list: (input?: PermissionRequestListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionRequestListOutput }>(
{
method: "GET",
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
create: (input: PermissionCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: PermissionCreateOutput }>(
{
@@ -1541,7 +1541,7 @@ export function make(options: ClientOptions) {
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
@@ -1553,7 +1553,7 @@ export function make(options: ClientOptions) {
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input["reply"], message: input["message"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
+10 -13
View File
@@ -320,10 +320,10 @@ export type FormMetadata = { [x: string]: JsonValue }
export type FormValue = string | number | boolean | Array<string>
export type PermissionSource = { type: "tool"; messageID: string; id: string }
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
export type PermissionSource = { type: "tool"; messageID: string; id: string }
export type FileSystemEntry = { path: string; type: "file" | "directory" }
export type CommandInfo = { name: string; description?: string }
@@ -5538,17 +5538,6 @@ export type FormCancelInput = {
export type FormCancelOutput = void
export type PermissionRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PermissionRequestListOutput = {
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
data: Array<PermissionRequest>
}
export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
export type PermissionSavedListOutput = { data: Array<PermissionSavedInfo> }["data"]
@@ -5557,6 +5546,14 @@ export type PermissionSavedRemoveInput = { readonly id: { readonly id: string }[
export type PermissionSavedRemoveOutput = void
export type PermissionRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PermissionRequestListOutput = { data: Array<PermissionRequest> }["data"]
export type PermissionCreateInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: {
+85 -252
View File
@@ -1,22 +1,18 @@
export * as Permission from "./permission.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { Agent } from "./agent.js"
import { SessionErrors } from "./session/error.js"
import { SessionSchema } from "./session/schema.js"
import { SessionStore } from "./session/store.js"
import { Wildcard } from "./util/wildcard.js"
import { PermissionSaved } from "./permission/saved.js"
import { PluginHooks } from "./plugin/hooks.js"
import { PermissionLedger } from "./permission/ledger.js"
import { PermissionPolicy } from "./permission/policy.js"
const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
export { Rule, Ruleset } from "@opencode-ai/schema/permission"
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export const ID = Permission.ID
export type ID = typeof ID.Type
@@ -24,34 +20,14 @@ export type ID = typeof ID.Type
export const Source = Permission.Source
export type Source = typeof Source.Type
const RequestFields = {
sessionID: Permission.Request.fields.sessionID,
action: Permission.Request.fields.action,
resources: Permission.Request.fields.resources,
save: Permission.Request.fields.save,
metadata: Permission.Request.fields.metadata,
source: Permission.Request.fields.source,
}
export const Request = Permission.Request
export type Request = typeof Request.Type
export const Reply = Permission.Reply
export type Reply = typeof Reply.Type
export const AssertInput = Schema.Struct({
id: ID.pipe(Schema.optional),
...RequestFields,
agent: Agent.ID.pipe(Schema.optional),
}).annotate({ identifier: "Permission.AssertInput" })
export type AssertInput = typeof AssertInput.Type
export const ReplyInput = Schema.Struct({
requestID: ID,
reply: Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "Permission.ReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
export { AssertInput, evaluate, merge } from "./permission/policy.js"
export { ReplyInput, DeclinedError, CorrectedError, NotFoundError } from "./permission/ledger.js"
export const AskResult = Schema.Struct({
id: ID,
@@ -61,12 +37,6 @@ export type AskResult = typeof AskResult.Type
export { Event } from "@opencode-ai/schema/permission"
export class DeclinedError extends Schema.TaggedError<DeclinedError>()("Permission.DeclinedError", {}) {}
export class CorrectedError extends Schema.TaggedError<CorrectedError>()("Permission.CorrectedError", {
feedback: Schema.String,
}) {}
export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission.BlockedError", {
rules: Permission.Ruleset,
permission: Schema.String,
@@ -78,32 +48,16 @@ export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission
}
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Permission.NotFoundError", {
requestID: ID,
}) {}
export type Error = BlockedError | CorrectedError
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
action,
resource: "*",
effect: "ask",
}
)
}
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
return rulesets.flat()
}
export type Error = BlockedError | PermissionLedger.CorrectedError
/**
* Location-scoped entry point composing PermissionPolicy (this Location's rules, agents,
* and hooks) with the host-wide PermissionLedger of pending requests.
*/
export interface Interface {
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly ask: (input: PermissionPolicy.AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: PermissionPolicy.AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: PermissionLedger.ReplyInput) => Effect.Effect<void, PermissionLedger.NotFoundError>
readonly get: (id: ID) => Effect.Effect<Request | undefined>
readonly forSession: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Request>>
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
@@ -111,222 +65,101 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Permission") {}
interface Pending {
readonly request: Request
readonly agent?: Agent.ID
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const location = yield* Location.Service
const agents = yield* Agent.Service
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
const policy = yield* PermissionPolicy.Service
const ledger = yield* PermissionLedger.Service
const scope = yield* Effect.scope
const ref = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID })
yield* Effect.addFinalizer(() =>
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
discard: true,
}).pipe(
Effect.ensuring(
Effect.sync(() => {
pending.clear()
}),
),
),
)
const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
(item): Permission.Rule => ({
action: item.action,
resource: item.resource,
effect: "allow",
}),
)
})
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
function relevant(input: AssertInput, rules: Permission.Ruleset) {
return rules.filter((rule) => Wildcard.match(input.action, rule.action))
}
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID, input.agent)
if (denied(input, rules)) return { effect: "deny" as const, rules }
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("ask") ? "ask" : "allow"
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
const register = (input: PermissionPolicy.AssertInput, message?: string) =>
ledger.register({
request: {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
message,
},
agent: input.agent,
action: input.action,
resources: input.resources,
metadata: input.metadata,
source: input.source,
effect,
location: ref,
projectID: location.project.id,
reevaluate: policy.evaluate(input).pipe(
Effect.map((result) => result.effect),
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
),
})
return { effect: event.effect, message: event.message, rules: all }
})
function request(input: AssertInput, message?: string): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
action: input.action,
resources: input.resources,
save: input.save,
metadata: input.metadata,
source: input.source,
message,
}
}
// Register and guard in one uninterruptible region: an interrupt that lands during
// registration fires the moment `restore` opens, so the guard must already be attached.
const settle = (
registration: PermissionLedger.Registration,
restore: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>,
) => restore(registration.await).pipe(Effect.ensuring(registration.cancel))
const create = (request: Request, agent?: Agent.ID) =>
Effect.uninterruptible(
const ask = Effect.fn("Permission.ask")(function* (input: PermissionPolicy.AssertInput) {
const result = yield* policy.evaluate(input)
if (result.effect !== "ask") return { id: input.id ?? ID.create(), effect: result.effect }
const registration = yield* Effect.uninterruptible(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
const item = { request, agent, deferred }
if (pending.has(request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${request.id}`))
pending.set(request.id, item)
yield* bus
.publish(Permission.Event.Asked, request)
.pipe(Effect.onError(() => Effect.sync(() => pending.delete(request.id))))
return item
const registration = yield* register(input, result.message)
// Nothing awaits a detached request, so park a waiter in this instance's scope:
// closing the instance cancels the request and clients drop the prompt.
yield* Effect.forkIn(
Effect.uninterruptibleMask((restore) => settle(registration, restore)).pipe(Effect.ignore),
scope,
{ startImmediately: true },
)
return registration
}),
)
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input, result.message)
if (result.effect === "ask") yield* create(value, input.agent)
return { id: value.id, effect: result.effect }
return { id: registration.request.id, effect: result.effect }
})
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
reason: result.message,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input, result.message), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
)
}),
)
const reply = Effect.fn("Permission.reply")((input: ReplyInput) =>
Effect.uninterruptible(
const assert = Effect.fn("Permission.assert")(function* (input: PermissionPolicy.AssertInput) {
const result = yield* policy.evaluate(input)
if (result.effect === "deny") {
return yield* new BlockedError({
rules: result.rules.filter((rule) => Wildcard.match(input.action, rule.action)),
permission: input.action,
resources: input.resources,
reason: result.message,
})
}
if (result.effect === "allow") return
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* bus.publish(Permission.Event.Replied, {
sessionID: existing.request.sessionID,
requestID: existing.request.id,
reply: input.reply,
})
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(),
)
pending.delete(input.requestID)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
yield* bus.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "reject",
})
yield* Deferred.fail(item.deferred, new DeclinedError())
pending.delete(id)
}
return
}
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: location.project.id,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
pending.delete(input.requestID)
if (input.reply !== "always" || !existing.request.save?.length) return
for (const [id, item] of pending) {
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
)
if (result?.effect !== "allow") continue
yield* bus.publish(Permission.Event.Replied, {
sessionID: item.request.sessionID,
requestID: item.request.id,
reply: "always",
})
yield* Deferred.succeed(item.deferred, undefined)
pending.delete(id)
}
const registration = yield* register(input, result.message)
return yield* settle(registration, restore).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
)
}),
),
)
const list = Effect.fn("Permission.list")(function* () {
return Array.from(pending.values(), (item) => item.request)
)
})
const get = Effect.fn("Permission.get")(function* (id: ID) {
return pending.get(id)?.request
return Service.of({
ask,
assert,
reply: ledger.reply,
get: ledger.get,
forSession: ledger.forSession,
list: () => ledger.list(),
})
const forSession = Effect.fn("Permission.forSession")(function* (sessionID: SessionSchema.ID) {
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
deps: [Location.node, PermissionPolicy.node, PermissionLedger.node],
})
+175
View File
@@ -0,0 +1,175 @@
export * as PermissionLedger from "./ledger.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Deferred, Effect, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Project } from "@opencode-ai/schema/project"
import { Agent } from "../agent.js"
import { Bus } from "../bus.js"
import { Location } from "../location.js"
import { SessionSchema } from "../session/schema.js"
import { PermissionSaved } from "./saved.js"
export const ReplyInput = Schema.Struct({
requestID: Permission.ID,
reply: Permission.Reply,
message: Schema.String.pipe(Schema.optional),
}).annotate({ identifier: "Permission.ReplyInput" })
export type ReplyInput = typeof ReplyInput.Type
export class DeclinedError extends Schema.TaggedError<DeclinedError>()("Permission.DeclinedError", {}) {}
export class CorrectedError extends Schema.TaggedError<CorrectedError>()("Permission.CorrectedError", {
feedback: Schema.String,
}) {}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Permission.NotFoundError", {
requestID: Permission.ID,
}) {}
export interface RegisterInput {
readonly request: Permission.Request
readonly agent?: Agent.ID
/** Placement of the asking instance; events route to clients watching it. */
readonly location: Location.Ref
readonly projectID: Project.ID
/** Re-runs the asker's policy after saved rules change; `undefined` once the Session is gone. */
readonly reevaluate: Effect.Effect<Permission.Effect | undefined>
}
/**
* A registered request stays pending until replied or cancelled. The registering fiber owns
* the entry: attach `cancel` with `Effect.ensuring` inside the uninterruptible region that
* registered, so an interrupt landing during registration cannot orphan it.
*/
export interface Registration {
readonly request: Permission.Request
readonly await: Effect.Effect<void, DeclinedError | CorrectedError>
/** Drops an unanswered request and tells clients it was rejected; no-op once replied. */
readonly cancel: Effect.Effect<void>
}
/** Host-wide pending permission requests, keyed by request and owned by the fiber awaiting each one. */
export interface Interface {
readonly register: (input: RegisterInput) => Effect.Effect<Registration>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
readonly get: (id: Permission.ID) => Effect.Effect<Permission.Request | undefined>
readonly forSession: (sessionID: SessionSchema.ID) => Effect.Effect<ReadonlyArray<Permission.Request>>
readonly list: (location?: Location.Ref) => Effect.Effect<ReadonlyArray<Permission.Request>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PermissionLedger") {}
interface Pending extends RegisterInput {
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const saved = yield* PermissionSaved.Service
const pending = new Map<Permission.ID, Pending>()
const replied = (item: Pending, reply: Permission.Reply) =>
bus.publish(
Permission.Event.Replied,
{ sessionID: item.request.sessionID, requestID: item.request.id, reply },
{ location: item.location },
)
// Only an abandoned asker reaches this with a live entry: a reply always removes it first.
const cancel = (id: Permission.ID) =>
Effect.gen(function* () {
const item = pending.get(id)
if (!item) return
pending.delete(id)
yield* Deferred.fail(item.deferred, new DeclinedError())
yield* replied(item, "reject")
})
const register = Effect.fn("PermissionLedger.register")((input: RegisterInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
if (pending.has(input.request.id))
return yield* Effect.die(new Error(`Duplicate pending permission ID: ${input.request.id}`))
pending.set(input.request.id, { ...input, deferred })
yield* bus
.publish(Permission.Event.Asked, input.request, { location: input.location })
.pipe(Effect.onError(() => Effect.sync(() => pending.delete(input.request.id))))
return {
request: input.request,
await: Deferred.await(deferred),
cancel: cancel(input.request.id),
} satisfies Registration
}),
),
)
const reply = Effect.fn("PermissionLedger.reply")((input: ReplyInput) =>
Effect.uninterruptible(
Effect.gen(function* () {
const existing = pending.get(input.requestID)
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
yield* replied(existing, input.reply)
// Remove before settling so the woken asker's cleanup finds nothing to cancel.
pending.delete(input.requestID)
if (input.reply === "reject") {
yield* Deferred.fail(
existing.deferred,
input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(),
)
for (const [id, item] of pending) {
if (item.request.sessionID !== existing.request.sessionID) continue
pending.delete(id)
yield* replied(item, "reject")
yield* Deferred.fail(item.deferred, new DeclinedError())
}
return
}
if (input.reply === "always" && existing.request.save?.length) {
yield* saved.add({
projectID: existing.projectID,
action: existing.request.action,
resources: existing.request.save,
})
}
yield* Deferred.succeed(existing.deferred, undefined)
if (input.reply !== "always" || !existing.request.save?.length) return
for (const [id, item] of pending) {
if ((yield* item.reevaluate) !== "allow") continue
pending.delete(id)
yield* replied(item, "always")
yield* Deferred.succeed(item.deferred, undefined)
}
}),
),
)
const list = Effect.fn("PermissionLedger.list")(function* (location?: Location.Ref) {
return Array.from(pending.values())
.filter(
(item) =>
!location ||
(item.location.directory === location.directory && item.location.workspaceID === location.workspaceID),
)
.map((item) => item.request)
})
const get = Effect.fn("PermissionLedger.get")(function* (id: Permission.ID) {
return pending.get(id)?.request
})
const forSession = Effect.fn("PermissionLedger.forSession")(function* (sessionID: SessionSchema.ID) {
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ register, reply, get, forSession, list })
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, PermissionSaved.node] })
+111
View File
@@ -0,0 +1,111 @@
export * as PermissionPolicy from "./policy.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Agent } from "../agent.js"
import { Location } from "../location.js"
import { SessionErrors } from "../session/error.js"
import { SessionSchema } from "../session/schema.js"
import { SessionStore } from "../session/store.js"
import { Wildcard } from "../util/wildcard.js"
import { PermissionSaved } from "./saved.js"
import { PluginHooks } from "../plugin/hooks.js"
const missingAgentPermissions: Permission.Ruleset = [{ action: "*", resource: "*", effect: "deny" }]
export const AssertInput = Schema.Struct({
id: Permission.ID.pipe(Schema.optional),
sessionID: Permission.Request.fields.sessionID,
action: Permission.Request.fields.action,
resources: Permission.Request.fields.resources,
save: Permission.Request.fields.save,
metadata: Permission.Request.fields.metadata,
source: Permission.Request.fields.source,
agent: Agent.ID.pipe(Schema.optional),
}).annotate({ identifier: "Permission.AssertInput" })
export type AssertInput = typeof AssertInput.Type
export interface Evaluation {
readonly effect: Permission.Effect
readonly message?: string
/** Rules consulted for the decision; a configured deny short-circuits before saved rules apply. */
readonly rules: Permission.Ruleset
}
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(action, rule.action) && Wildcard.match(resource, rule.resource)) ?? {
action,
resource: "*",
effect: "ask",
}
)
}
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
return rulesets.flat()
}
export interface Interface {
readonly evaluate: (input: AssertInput) => Effect.Effect<Evaluation, SessionErrors.NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PermissionPolicy") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const agents = yield* Agent.Service
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const savedRules = Effect.fnUntraced(function* () {
return (yield* saved.list({ projectID: location.project.id })).map(
(item): Permission.Rule => ({
action: item.action,
resource: item.resource,
effect: "allow",
}),
)
})
const configured = Effect.fnUntraced(function* (sessionID: SessionSchema.ID, agentID?: Agent.ID) {
const session = yield* sessions.get(sessionID)
if (!session) return yield* new SessionErrors.NotFoundError({ sessionID })
const agent = yield* agents.resolve(agentID ?? session.agent)
return agent?.permissions ?? missingAgentPermissions
})
const evaluateInput = Effect.fn("PermissionPolicy.evaluate")(function* (input: AssertInput) {
const rules = yield* configured(input.sessionID, input.agent)
if (input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny"))
return { effect: "deny", rules } satisfies Evaluation
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("ask") ? "ask" : "allow"
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
agent: input.agent,
action: input.action,
resources: input.resources,
metadata: input.metadata,
source: input.source,
effect,
})
return { effect: event.effect, message: event.message, rules: all } satisfies Evaluation
})
return Service.of({ evaluate: evaluateInput })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
})
+112 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { Cause, Context, Deferred, Effect, Fiber, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -7,8 +7,10 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionLedger } from "@opencode-ai/core/permission/ledger"
import { PermissionTable } from "@opencode-ai/core/permission/sql"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -26,7 +28,16 @@ const current = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionStore.node, PermissionSaved.node, Agent.node, Permission.node]),
LayerNode.group([
Database.node,
Bus.node,
SessionStore.node,
PermissionSaved.node,
Agent.node,
PluginHooks.node,
PermissionLedger.node,
Permission.node,
]),
[Location.node.replace(current)],
),
)
@@ -245,6 +256,27 @@ describe("Permission", () => {
}),
)
it.effect("lets plugin hooks override the evaluated effect", () =>
Effect.gen(function* () {
yield* setup([{ action: "read", resource: "*", effect: "allow" }])
const hooks = yield* PluginHooks.Service
yield* hooks.register("permission", "evaluate", (event) =>
Effect.sync(() => {
if (event.action !== "read") return
event.effect = "ask"
event.message = "review reads"
}),
)
const service = yield* Permission.Service
expect(yield* service.ask(assertion())).toEqual({ id: Permission.ID.create("per_test"), effect: "ask" })
expect(yield* service.get(Permission.ID.create("per_test"))).toMatchObject({ message: "review reads" })
expect(yield* service.ask(assertion({ id: Permission.ID.create("per_write"), action: "write" }))).toMatchObject({
effect: "ask",
})
expect(yield* service.get(Permission.ID.create("per_write"))).not.toHaveProperty("message", "review reads")
}),
)
it.effect("resolves an asked permission once", () =>
Effect.gen(function* () {
yield* setup()
@@ -278,6 +310,84 @@ describe("Permission", () => {
}),
)
it.effect("shares pending requests through the host-wide ledger", () =>
Effect.gen(function* () {
yield* setup()
const { fiber, request } = yield* waitForRequest()
const ledger = yield* PermissionLedger.Service
expect(yield* ledger.forSession(request.sessionID)).toEqual([request])
expect(yield* ledger.get(request.id)).toEqual(request)
expect(yield* ledger.list()).toEqual([request])
expect(yield* ledger.list(Location.Ref.make({ directory: AbsolutePath.make("/project") }))).toEqual([request])
expect(yield* ledger.list(Location.Ref.make({ directory: AbsolutePath.make("/elsewhere") }))).toEqual([])
yield* ledger.reply({ requestID: request.id, reply: "once" })
yield* Fiber.join(fiber)
expect(yield* ledger.list()).toEqual([])
}),
)
it.effect("cancels an interrupted asker and tells clients", () =>
Effect.gen(function* () {
yield* setup()
const bus = yield* Bus.Service
const replied: Array<{ location?: Location.Ref; data: unknown }> = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Permission.Event.Replied.type) replied.push({ location: event.location, data: event.data })
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const { service, fiber, request } = yield* waitForRequest()
yield* Fiber.interrupt(fiber)
expect(yield* service.list()).toEqual([])
expect(replied).toEqual([
{
location: { directory: AbsolutePath.make("/project"), workspaceID: undefined },
data: { sessionID: request.sessionID, requestID: request.id, reply: "reject" },
},
])
}),
)
it.effect("cancels detached requests when their instance closes", () =>
Effect.gen(function* () {
yield* setup()
const bus = yield* Bus.Service
const ledger = yield* PermissionLedger.Service
const replied: unknown[] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.sync(() => {
if (event.type === Permission.Event.Replied.type) replied.push(event.data)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
// A second facade standing on the same global services stands in for another instance.
const instance = LayerNode.compile(LayerNode.group([Permission.node]), {
replacements: [
Location.node.replace(current),
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
PermissionLedger.node.replace(Layer.succeed(PermissionLedger.Service, ledger)),
SessionStore.node.replace(Layer.succeed(SessionStore.Service, yield* SessionStore.Service)),
PermissionSaved.node.replace(Layer.succeed(PermissionSaved.Service, yield* PermissionSaved.Service)),
Agent.node.replace(Layer.succeed(Agent.Service, yield* Agent.Service)),
],
})
const asked = yield* Effect.scoped(
Effect.gen(function* () {
// A fresh memo map keeps this build from adopting the harness's own facade.
const context = yield* Layer.buildWithMemoMap(instance, Layer.makeMemoMapUnsafe(), yield* Effect.scope)
const service = Context.get(context, Permission.Service)
const asked = yield* service.ask(assertion())
expect(asked.effect).toBe("ask")
expect(yield* ledger.get(asked.id)).toBeDefined()
return asked
}),
)
expect(yield* ledger.get(asked.id)).toBeUndefined()
expect(replied).toEqual([{ sessionID: Session.ID.make("ses_test"), requestID: asked.id, reply: "reject" }])
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()
+31 -34
View File
@@ -1,5 +1,4 @@
import { Agent } from "@opencode-ai/schema/agent"
import { Location } from "@opencode-ai/schema/location"
import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Project } from "@opencode-ai/schema/project"
@@ -19,20 +18,6 @@ export const makePermissionGroup = <
sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
) =>
HttpApiGroup.make("server.permission")
.add(
HttpApiEndpoint.get("permission.request.list", "/api/permission/request", {
query: LocationQuery,
success: Location.response(Schema.Array(Permission.Request)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
summary: "List pending permission requests",
description: "Retrieve pending permission requests for a location.",
}),
),
)
.add(
HttpApiEndpoint.get("permission.saved.list", "/api/permission/saved", {
query: Schema.Struct({ projectID: Project.ID.pipe(Schema.optional) }),
@@ -57,8 +42,24 @@ export const makePermissionGroup = <
}),
),
)
// Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
// Effect applies group middleware only to endpoints already added. Pending requests live in a
// host-wide ledger, so the routes below resolve a Location only when evaluating policy.
.middleware(locationMiddleware)
.add(
HttpApiEndpoint.get("permission.request.list", "/api/permission/request", {
query: LocationQuery,
success: Schema.Struct({ data: Schema.Array(Permission.Request) }),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
summary: "List pending permission requests",
description:
"Retrieve pending permission requests across the host, or only those asked from the given location.",
}),
),
)
.add(
HttpApiEndpoint.post("session.permission.create", "/api/session/:sessionID/permission", {
params: { sessionID: Session.ID },
@@ -103,15 +104,13 @@ export const makePermissionGroup = <
params: { sessionID: Session.ID, requestID: Permission.ID },
success: Schema.Struct({ data: Permission.Request }),
error: [SessionNotFoundError, PermissionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.get",
summary: "Get permission request",
description: "Retrieve a pending permission request owned by a session.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.get",
summary: "Get permission request",
description: "Retrieve a pending permission request owned by a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", {
@@ -122,14 +121,12 @@ export const makePermissionGroup = <
}),
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, PermissionNotFoundError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
+25 -18
View File
@@ -1,13 +1,15 @@
import { Instance } from "@opencode-ai/core/instance/service"
import { Location } from "@opencode-ai/core/location"
import { Permission } from "@opencode-ai/core/permission"
import { PermissionLedger } from "@opencode-ai/core/permission/ledger"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Workspace } from "@opencode-ai/core/workspace"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { PermissionNotFoundError } from "@opencode-ai/protocol/errors"
import { response, sessionInfo } from "../location"
import { sessionInfo } from "../location"
import { missingSession } from "./session-error"
function missingRequest(id: Permission.ID) {
@@ -16,24 +18,33 @@ function missingRequest(id: Permission.ID) {
export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) =>
Effect.gen(function* () {
const instances = yield* Instance.Service
const ledger = yield* PermissionLedger.Service
const sessions = yield* Session.Service
// Pending requests live in the host-wide ledger, so Session routes never boot the Session's instance.
const requireOwnedRequest = Effect.fnUntraced(function* (
sessionID: Permission.Request["sessionID"],
requestID: Permission.ID,
) {
const permission = yield* Permission.Service
const request = yield* permission.get(requestID)
yield* sessionInfo(sessions, sessionID)
const request = yield* ledger.get(requestID)
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
return { permission, request }
return request
})
return handlers
.handle(
"permission.request.list",
Effect.fn(function* () {
const permission = yield* Permission.Service
return yield* response(permission.list())
Effect.fn(function* (ctx) {
const directory = ctx.query.location?.directory
const location = directory
? Location.Ref.make({
directory: AbsolutePath.make(directory),
workspaceID: ctx.query.location?.workspace
? Workspace.ID.make(ctx.query.location.workspace)
: undefined,
})
: undefined
return { data: yield* ledger.list(location) }
}),
)
.handle(
@@ -59,25 +70,21 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
.handle(
"session.permission.list",
Effect.fn(function* (ctx) {
const session = yield* sessionInfo(sessions, ctx.params.sessionID)
const requests = yield* Permission.Service.use((permission) =>
permission.forSession(ctx.params.sessionID),
).pipe(instances.provide(session))
return { data: requests }
yield* sessionInfo(sessions, ctx.params.sessionID)
return { data: yield* ledger.forSession(ctx.params.sessionID) }
}),
)
.handle(
"session.permission.get",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedRequest(ctx.params.sessionID, ctx.params.requestID)
return { data: owned.request }
return { data: yield* requireOwnedRequest(ctx.params.sessionID, ctx.params.requestID) }
}),
)
.handle(
"session.permission.reply",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedRequest(ctx.params.sessionID, ctx.params.requestID)
yield* owned.permission
yield* requireOwnedRequest(ctx.params.sessionID, ctx.params.requestID)
yield* ledger
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.pipe(Effect.catchTag("Permission.NotFoundError", () => missingRequest(ctx.params.requestID)))
return HttpApiSchema.NoContent.make()
+2
View File
@@ -9,6 +9,7 @@ import { EventLogger } from "@opencode-ai/core/event-logger"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Credential } from "@opencode-ai/core/credential"
import { Config } from "@opencode-ai/core/config"
import { PermissionLedger } from "@opencode-ai/core/permission/ledger"
import { PermissionSaved } from "@opencode-ai/core/permission/saved"
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { PersistentPty } from "@opencode-ai/core/persistent-pty"
@@ -61,6 +62,7 @@ const applicationServiceNodes = [
SessionTransfer.node,
SdkPlugins.node,
PluginUpdate.node,
PermissionLedger.node,
PermissionSaved.node,
PtyTicket.node,
PersistentPty.node,
+31 -8
View File
@@ -38,6 +38,8 @@ it.live(
const first = { id: Session.ID.make("ses_instance_first"), tool: "instance_first", temperature: 0.1 }
const second = { id: Session.ID.make("ses_instance_second"), tool: "instance_second", temperature: 0.2 }
const configs = [first, second]
// Never configured, so any attempt to boot its instance throws.
const third = Session.ID.make("ses_instance_third")
const boots: Session.ID[] = []
const executed: Session.ID[] = []
const commands: Session.ID[] = []
@@ -176,7 +178,7 @@ it.live(
),
)
yield* Effect.forEach(configs, (config) =>
yield* Effect.forEach([...configs, { id: third, tool: "instance_third" }], (config) =>
sessions.create({
id: config.id,
title: config.tool,
@@ -185,7 +187,7 @@ it.live(
location,
}),
)
expect((yield* sessions.list()).data.map((session) => session.location)).toEqual([location, location])
expect((yield* sessions.list()).data.map((session) => session.location)).toEqual([location, location, location])
expect(yield* sessions.messages({ sessionID: first.id })).toEqual([])
yield* sessions.get(second.id)
for (const config of configs) {
@@ -257,11 +259,14 @@ it.live(
title: "Foreign form",
fields: [{ key: "answer", type: "string" }],
})
const foreignPermission = yield* permissions.ask({
// Asked from this instance on behalf of the never-booted third Session.
const foreignPermission = {
...permission,
id: Permission.ID.create(),
sessionID: foreignID,
})
sessionID: third,
message: config.tool,
}
expect(yield* permissions.ask(foreignPermission)).toEqual({ id: foreignPermission.id, effect: "ask" })
return {
session,
form,
@@ -299,11 +304,29 @@ it.live(
const forms = yield* Form.Service
const permissions = yield* Permission.Service
expect(yield* forms.state(entry.foreignForm.id)).toEqual({ status: "pending" })
expect(yield* permissions.get(entry.foreignPermission.id)).toMatchObject({
sessionID: entry.foreignForm.sessionID,
})
expect(yield* permissions.get(entry.foreignPermission.id)).toMatchObject({ sessionID: third })
}).pipe(instances.provide(entry.session))
}
// The ledger is host-wide: the third Session's requests are visible and answerable without booting it.
const foreign = yield* request(`/api/session/${third}/permission`)
expect(foreign.status).toBe(200)
expect(yield* Effect.promise<unknown>(() => foreign.json())).toEqual({
data: pending.map((entry) => entry.foreignPermission),
})
const everywhere = yield* request(`/api/permission/request`)
expect(everywhere.status).toBe(200)
expect(yield* Effect.promise<unknown>(() => everywhere.json())).toEqual({
data: pending.flatMap((entry) => [entry.permission, entry.foreignPermission]),
})
for (const entry of pending) {
expect(
(yield* request(`/api/session/${third}/permission/${entry.foreignPermission.id}/reply`, { reply: "once" }))
.status,
).toBe(204)
}
const answered = yield* request(`/api/session/${third}/permission`)
expect(yield* Effect.promise<unknown>(() => answered.json())).toEqual({ data: [] })
expect(boots).toEqual([first.id, second.id])
for (const entry of pending) {
expect(
(yield* request(`/api/session/${entry.session.id}/form/${entry.form.id}/reply`, {