mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-15 05:16:26 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea992bb5bc |
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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] })
|
||||
@@ -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],
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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." }))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
Reference in New Issue
Block a user