summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-03-14 14:28:00 -0400
committerGitHub <[email protected]>2026-03-14 18:28:00 +0000
commitf015154314e4b8b8ef6b0454e566561ee91bbefc (patch)
tree744b21cc3c875d83cbb134052b7775d98af75d5d
parent689d9e14eade9001568c46c602092eb01fe7e746 (diff)
downloadopencode-f015154314e4b8b8ef6b0454e566561ee91bbefc.tar.gz
opencode-f015154314e4b8b8ef6b0454e566561ee91bbefc.zip
refactor(permission): effectify PermissionNext + fix InstanceState ALS bug (#17511)
-rw-r--r--packages/opencode/src/cli/cmd/debug/agent.ts2
-rw-r--r--packages/opencode/src/effect/runtime.ts3
-rw-r--r--packages/opencode/src/permission/index.ts2
-rw-r--r--packages/opencode/src/permission/next.ts264
-rw-r--r--packages/opencode/src/permission/schema.ts20
-rw-r--r--packages/opencode/src/permission/service.ts265
-rw-r--r--packages/opencode/src/question/index.ts2
-rw-r--r--packages/opencode/src/question/service.ts27
-rw-r--r--packages/opencode/src/util/instance-state.ts8
-rw-r--r--packages/opencode/test/permission/next.test.ts349
-rw-r--r--packages/opencode/test/tool/read.test.ts2
-rw-r--r--packages/opencode/test/util/instance-state.test.ts125
12 files changed, 805 insertions, 264 deletions
diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts
index 297a7ec02..ef075d732 100644
--- a/packages/opencode/src/cli/cmd/debug/agent.ts
+++ b/packages/opencode/src/cli/cmd/debug/agent.ts
@@ -159,7 +159,7 @@ async function createToolContext(agent: Agent.Info) {
for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, ruleset)
if (rule.action === "deny") {
- throw new PermissionNext.DeniedError(ruleset)
+ throw new PermissionNext.DeniedError({ ruleset })
}
}
},
diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts
index de4bc3dda..4aec46bef 100644
--- a/packages/opencode/src/effect/runtime.ts
+++ b/packages/opencode/src/effect/runtime.ts
@@ -1,8 +1,9 @@
import { Layer, ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
import { AuthService } from "@/auth/service"
+import { PermissionService } from "@/permission/service"
import { QuestionService } from "@/question/service"
export const runtime = ManagedRuntime.make(
- Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, QuestionService.layer),
+ Layer.mergeAll(AccountService.defaultLayer, AuthService.defaultLayer, PermissionService.layer, QuestionService.layer),
)
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index 565ccf20d..9cdaf313b 100644
--- a/packages/opencode/src/permission/index.ts
+++ b/packages/opencode/src/permission/index.ts
@@ -87,7 +87,7 @@ export namespace Permission {
result.push(item.info)
}
}
- return result.sort((a, b) => a.id.localeCompare(b.id))
+ return result.sort((a, b) => String(a.id).localeCompare(String(b.id)))
}
export async function ask(input: {
diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts
index 3ef3a0230..7fcd40eea 100644
--- a/packages/opencode/src/permission/next.ts
+++ b/packages/opencode/src/permission/next.ts
@@ -1,21 +1,20 @@
-import { Bus } from "@/bus"
-import { BusEvent } from "@/bus/bus-event"
+import { runtime } from "@/effect/runtime"
import { Config } from "@/config/config"
-import { SessionID, MessageID } from "@/session/schema"
-import { PermissionID } from "./schema"
-import { Instance } from "@/project/instance"
-import { Database, eq } from "@/storage/db"
-import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn"
-import { Log } from "@/util/log"
-import { ProjectID } from "@/project/schema"
import { Wildcard } from "@/util/wildcard"
+import { Effect } from "effect"
import os from "os"
-import z from "zod"
+import * as S from "./service"
+import type {
+ Action as ActionType,
+ PermissionError,
+ Reply as ReplyType,
+ Request as RequestType,
+ Rule as RuleType,
+ Ruleset as RulesetType,
+} from "./service"
export namespace PermissionNext {
- const log = Log.create({ service: "permission" })
-
function expand(pattern: string): string {
if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1)
if (pattern === "~") return os.homedir()
@@ -24,26 +23,26 @@ export namespace PermissionNext {
return pattern
}
- export const Action = z.enum(["allow", "deny", "ask"]).meta({
- ref: "PermissionAction",
- })
- export type Action = z.infer<typeof Action>
-
- export const Rule = z
- .object({
- permission: z.string(),
- pattern: z.string(),
- action: Action,
- })
- .meta({
- ref: "PermissionRule",
- })
- export type Rule = z.infer<typeof Rule>
+ function runPromise<A>(f: (service: S.PermissionService.Api) => Effect.Effect<A, PermissionError>) {
+ return runtime.runPromise(S.PermissionService.use(f))
+ }
- export const Ruleset = Rule.array().meta({
- ref: "PermissionRuleset",
- })
- export type Ruleset = z.infer<typeof Ruleset>
+ export const Action = S.Action
+ export type Action = ActionType
+ export const Rule = S.Rule
+ export type Rule = RuleType
+ export const Ruleset = S.Ruleset
+ export type Ruleset = RulesetType
+ export const Request = S.Request
+ export type Request = RequestType
+ export const Reply = S.Reply
+ export type Reply = ReplyType
+ export const Approval = S.Approval
+ export const Event = S.Event
+ export const Service = S.PermissionService
+ export const RejectedError = S.RejectedError
+ export const CorrectedError = S.CorrectedError
+ export const DeniedError = S.DeniedError
export function fromConfig(permission: Config.Permission) {
const ruleset: Ruleset = []
@@ -67,178 +66,16 @@ export namespace PermissionNext {
return rulesets.flat()
}
- export const Request = z
- .object({
- id: PermissionID.zod,
- sessionID: SessionID.zod,
- permission: z.string(),
- patterns: z.string().array(),
- metadata: z.record(z.string(), z.any()),
- always: z.string().array(),
- tool: z
- .object({
- messageID: MessageID.zod,
- callID: z.string(),
- })
- .optional(),
- })
- .meta({
- ref: "PermissionRequest",
- })
-
- export type Request = z.infer<typeof Request>
+ export const ask = fn(S.AskInput, async (input) => runPromise((service) => service.ask(input)))
- export const Reply = z.enum(["once", "always", "reject"])
- export type Reply = z.infer<typeof Reply>
+ export const reply = fn(S.ReplyInput, async (input) => runPromise((service) => service.reply(input)))
- export const Approval = z.object({
- projectID: ProjectID.zod,
- patterns: z.string().array(),
- })
-
- export const Event = {
- Asked: BusEvent.define("permission.asked", Request),
- Replied: BusEvent.define(
- "permission.replied",
- z.object({
- sessionID: SessionID.zod,
- requestID: PermissionID.zod,
- reply: Reply,
- }),
- ),
- }
-
- interface PendingEntry {
- info: Request
- resolve: () => void
- reject: (e: any) => void
+ export async function list() {
+ return runPromise((service) => service.list())
}
- const state = Instance.state(() => {
- const projectID = Instance.project.id
- const row = Database.use((db) =>
- db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(),
- )
- const stored = row?.data ?? ([] as Ruleset)
-
- return {
- pending: new Map<PermissionID, PendingEntry>(),
- approved: stored,
- }
- })
-
- export const ask = fn(
- Request.partial({ id: true }).extend({
- ruleset: Ruleset,
- }),
- async (input) => {
- const s = await state()
- const { ruleset, ...request } = input
- for (const pattern of request.patterns ?? []) {
- const rule = evaluate(request.permission, pattern, ruleset, s.approved)
- log.info("evaluated", { permission: request.permission, pattern, action: rule })
- if (rule.action === "deny")
- throw new DeniedError(ruleset.filter((r) => Wildcard.match(request.permission, r.permission)))
- if (rule.action === "ask") {
- const id = input.id ?? PermissionID.ascending()
- return new Promise<void>((resolve, reject) => {
- const info: Request = {
- id,
- ...request,
- }
- s.pending.set(id, {
- info,
- resolve,
- reject,
- })
- Bus.publish(Event.Asked, info)
- })
- }
- if (rule.action === "allow") continue
- }
- },
- )
-
- export const reply = fn(
- z.object({
- requestID: PermissionID.zod,
- reply: Reply,
- message: z.string().optional(),
- }),
- async (input) => {
- const s = await state()
- const existing = s.pending.get(input.requestID)
- if (!existing) return
- s.pending.delete(input.requestID)
- Bus.publish(Event.Replied, {
- sessionID: existing.info.sessionID,
- requestID: existing.info.id,
- reply: input.reply,
- })
- if (input.reply === "reject") {
- existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError())
- // Reject all other pending permissions for this session
- const sessionID = existing.info.sessionID
- for (const [id, pending] of s.pending) {
- if (pending.info.sessionID === sessionID) {
- s.pending.delete(id)
- Bus.publish(Event.Replied, {
- sessionID: pending.info.sessionID,
- requestID: pending.info.id,
- reply: "reject",
- })
- pending.reject(new RejectedError())
- }
- }
- return
- }
- if (input.reply === "once") {
- existing.resolve()
- return
- }
- if (input.reply === "always") {
- for (const pattern of existing.info.always) {
- s.approved.push({
- permission: existing.info.permission,
- pattern,
- action: "allow",
- })
- }
-
- existing.resolve()
-
- const sessionID = existing.info.sessionID
- for (const [id, pending] of s.pending) {
- if (pending.info.sessionID !== sessionID) continue
- const ok = pending.info.patterns.every(
- (pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow",
- )
- if (!ok) continue
- s.pending.delete(id)
- Bus.publish(Event.Replied, {
- sessionID: pending.info.sessionID,
- requestID: pending.info.id,
- reply: "always",
- })
- pending.resolve()
- }
-
- // TODO: we don't save the permission ruleset to disk yet until there's
- // UI to manage it
- // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
- // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
- return
- }
- },
- )
-
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
- const merged = merge(...rulesets)
- log.info("evaluate", { permission, pattern, ruleset: merged })
- const match = merged.findLast(
- (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
- )
- return match ?? { action: "ask", permission, pattern: "*" }
+ return S.evaluate(permission, pattern, ...rulesets)
}
const EDIT_TOOLS = ["edit", "write", "patch", "multiedit"]
@@ -247,39 +84,10 @@ export namespace PermissionNext {
const result = new Set<string>()
for (const tool of tools) {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
-
- const rule = ruleset.findLast((r) => Wildcard.match(permission, r.permission))
+ const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
if (!rule) continue
if (rule.pattern === "*" && rule.action === "deny") result.add(tool)
}
return result
}
-
- /** User rejected without message - halts execution */
- export class RejectedError extends Error {
- constructor() {
- super(`The user rejected permission to use this specific tool call.`)
- }
- }
-
- /** User rejected with message - continues with guidance */
- export class CorrectedError extends Error {
- constructor(message: string) {
- super(`The user rejected permission to use this specific tool call with the following feedback: ${message}`)
- }
- }
-
- /** Auto-rejected by config rule - halts execution */
- export class DeniedError extends Error {
- constructor(public readonly ruleset: Ruleset) {
- super(
- `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(ruleset)}`,
- )
- }
- }
-
- export async function list() {
- const s = await state()
- return Array.from(s.pending.values(), (x) => x.info)
- }
}
diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts
index c3242b714..bfa2b4957 100644
--- a/packages/opencode/src/permission/schema.ts
+++ b/packages/opencode/src/permission/schema.ts
@@ -2,16 +2,16 @@ import { Schema } from "effect"
import z from "zod"
import { Identifier } from "@/id/id"
-import { withStatics } from "@/util/schema"
+import { Newtype } from "@/util/schema"
-const permissionIdSchema = Schema.String.pipe(Schema.brand("PermissionID"))
+export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
+ static make(id: string): PermissionID {
+ return this.makeUnsafe(id)
+ }
-export type PermissionID = typeof permissionIdSchema.Type
+ static ascending(id?: string): PermissionID {
+ return this.makeUnsafe(Identifier.ascending("permission", id))
+ }
-export const PermissionID = permissionIdSchema.pipe(
- withStatics((schema: typeof permissionIdSchema) => ({
- make: (id: string) => schema.makeUnsafe(id),
- ascending: (id?: string) => schema.makeUnsafe(Identifier.ascending("permission", id)),
- zod: Identifier.schema("permission").pipe(z.custom<PermissionID>()),
- })),
-)
+ static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID>
+}
diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts
new file mode 100644
index 000000000..2782c0aba
--- /dev/null
+++ b/packages/opencode/src/permission/service.ts
@@ -0,0 +1,265 @@
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Instance } from "@/project/instance"
+import { ProjectID } from "@/project/schema"
+import { MessageID, SessionID } from "@/session/schema"
+import { PermissionTable } from "@/session/session.sql"
+import { Database, eq } from "@/storage/db"
+import { InstanceState } from "@/util/instance-state"
+import { Log } from "@/util/log"
+import { Wildcard } from "@/util/wildcard"
+import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
+import z from "zod"
+import { PermissionID } from "./schema"
+
+const log = Log.create({ service: "permission" })
+
+export const Action = z.enum(["allow", "deny", "ask"]).meta({
+ ref: "PermissionAction",
+})
+export type Action = z.infer<typeof Action>
+
+export const Rule = z
+ .object({
+ permission: z.string(),
+ pattern: z.string(),
+ action: Action,
+ })
+ .meta({
+ ref: "PermissionRule",
+ })
+export type Rule = z.infer<typeof Rule>
+
+export const Ruleset = Rule.array().meta({
+ ref: "PermissionRuleset",
+})
+export type Ruleset = z.infer<typeof Ruleset>
+
+export const Request = z
+ .object({
+ id: PermissionID.zod,
+ sessionID: SessionID.zod,
+ permission: z.string(),
+ patterns: z.string().array(),
+ metadata: z.record(z.string(), z.any()),
+ always: z.string().array(),
+ tool: z
+ .object({
+ messageID: MessageID.zod,
+ callID: z.string(),
+ })
+ .optional(),
+ })
+ .meta({
+ ref: "PermissionRequest",
+ })
+export type Request = z.infer<typeof Request>
+
+export const Reply = z.enum(["once", "always", "reject"])
+export type Reply = z.infer<typeof Reply>
+
+export const Approval = z.object({
+ projectID: ProjectID.zod,
+ patterns: z.string().array(),
+})
+
+export const Event = {
+ Asked: BusEvent.define("permission.asked", Request),
+ Replied: BusEvent.define(
+ "permission.replied",
+ z.object({
+ sessionID: SessionID.zod,
+ requestID: PermissionID.zod,
+ reply: Reply,
+ }),
+ ),
+}
+
+export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
+ override get message() {
+ return "The user rejected permission to use this specific tool call."
+ }
+}
+
+export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionCorrectedError", {
+ feedback: Schema.String,
+}) {
+ override get message() {
+ return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}`
+ }
+}
+
+export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionDeniedError", {
+ ruleset: Schema.Any,
+}) {
+ override get message() {
+ return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}`
+ }
+}
+
+export type PermissionError = DeniedError | RejectedError | CorrectedError
+
+interface PendingEntry {
+ info: Request
+ deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
+}
+
+type State = {
+ pending: Map<PermissionID, PendingEntry>
+ approved: Ruleset
+}
+
+export const AskInput = Request.partial({ id: true }).extend({
+ ruleset: Ruleset,
+})
+
+export const ReplyInput = z.object({
+ requestID: PermissionID.zod,
+ reply: Reply,
+ message: z.string().optional(),
+})
+
+export declare namespace PermissionService {
+ export interface Api {
+ readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, PermissionError>
+ readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
+ readonly list: () => Effect.Effect<Request[]>
+ }
+}
+
+export class PermissionService extends ServiceMap.Service<PermissionService, PermissionService.Api>()(
+ "@opencode/PermissionNext",
+) {
+ static readonly layer = Layer.effect(
+ PermissionService,
+ Effect.gen(function* () {
+ const instanceState = yield* InstanceState.make<State>(() =>
+ Effect.sync(() => {
+ const row = Database.use((db) =>
+ db.select().from(PermissionTable).where(eq(PermissionTable.project_id, Instance.project.id)).get(),
+ )
+ return {
+ pending: new Map<PermissionID, PendingEntry>(),
+ approved: row?.data ?? [],
+ }
+ }),
+ )
+
+ const ask = Effect.fn("PermissionService.ask")(function* (input: z.infer<typeof AskInput>) {
+ const state = yield* InstanceState.get(instanceState)
+ const { ruleset, ...request } = input
+ let pending = false
+
+ for (const pattern of request.patterns) {
+ const rule = evaluate(request.permission, pattern, ruleset, state.approved)
+ log.info("evaluated", { permission: request.permission, pattern, action: rule })
+ if (rule.action === "deny") {
+ return yield* new DeniedError({
+ ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)),
+ })
+ }
+ if (rule.action === "allow") continue
+ pending = true
+ }
+
+ if (!pending) return
+
+ const id = request.id ?? PermissionID.ascending()
+ const info: Request = {
+ id,
+ ...request,
+ }
+ log.info("asking", { id, permission: info.permission, patterns: info.patterns })
+
+ const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
+ state.pending.set(id, { info, deferred })
+ void Bus.publish(Event.Asked, info)
+ return yield* Effect.ensuring(
+ Deferred.await(deferred),
+ Effect.sync(() => {
+ state.pending.delete(id)
+ }),
+ )
+ })
+
+ const reply = Effect.fn("PermissionService.reply")(function* (input: z.infer<typeof ReplyInput>) {
+ const state = yield* InstanceState.get(instanceState)
+ const existing = state.pending.get(input.requestID)
+ if (!existing) return
+
+ state.pending.delete(input.requestID)
+ void Bus.publish(Event.Replied, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ reply: input.reply,
+ })
+
+ if (input.reply === "reject") {
+ yield* Deferred.fail(
+ existing.deferred,
+ input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
+ )
+
+ for (const [id, item] of state.pending.entries()) {
+ if (item.info.sessionID !== existing.info.sessionID) continue
+ state.pending.delete(id)
+ void Bus.publish(Event.Replied, {
+ sessionID: item.info.sessionID,
+ requestID: item.info.id,
+ reply: "reject",
+ })
+ yield* Deferred.fail(item.deferred, new RejectedError())
+ }
+ return
+ }
+
+ yield* Deferred.succeed(existing.deferred, undefined)
+ if (input.reply === "once") return
+
+ for (const pattern of existing.info.always) {
+ state.approved.push({
+ permission: existing.info.permission,
+ pattern,
+ action: "allow",
+ })
+ }
+
+ for (const [id, item] of state.pending.entries()) {
+ if (item.info.sessionID !== existing.info.sessionID) continue
+ const ok = item.info.patterns.every(
+ (pattern) => evaluate(item.info.permission, pattern, state.approved).action === "allow",
+ )
+ if (!ok) continue
+ state.pending.delete(id)
+ void Bus.publish(Event.Replied, {
+ sessionID: item.info.sessionID,
+ requestID: item.info.id,
+ reply: "always",
+ })
+ yield* Deferred.succeed(item.deferred, undefined)
+ }
+
+ // TODO: we don't save the permission ruleset to disk yet until there's
+ // UI to manage it
+ // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
+ // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
+ })
+
+ const list = Effect.fn("PermissionService.list")(function* () {
+ const state = yield* InstanceState.get(instanceState)
+ return Array.from(state.pending.values(), (item) => item.info)
+ })
+
+ return PermissionService.of({ ask, reply, list })
+ }),
+ )
+}
+
+export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
+ const merged = rulesets.flat()
+ log.info("evaluate", { permission, pattern, ruleset: merged })
+ const match = merged.findLast(
+ (rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
+ )
+ return match ?? { action: "ask", permission, pattern: "*" }
+}
diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts
index fc0c7dd41..6ace981a9 100644
--- a/packages/opencode/src/question/index.ts
+++ b/packages/opencode/src/question/index.ts
@@ -4,7 +4,7 @@ import * as S from "./service"
import type { QuestionID } from "./schema"
import type { SessionID, MessageID } from "@/session/schema"
-function runPromise<A>(f: (service: S.QuestionService.Service) => Effect.Effect<A, S.QuestionServiceError>) {
+function runPromise<A, E>(f: (service: S.QuestionService.Service) => Effect.Effect<A, E>) {
return runtime.runPromise(S.QuestionService.use(f))
}
diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts
index 6b353c7f1..4a81946e8 100644
--- a/packages/opencode/src/question/service.ts
+++ b/packages/opencode/src/question/service.ts
@@ -72,22 +72,17 @@ export const Event = {
),
}
-export class RejectedError extends Error {
- constructor() {
- super("The user dismissed this question")
+export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
+ override get message() {
+ return "The user dismissed this question"
}
}
// --- Effect service ---
-export class QuestionServiceError extends Schema.TaggedErrorClass<QuestionServiceError>()("QuestionServiceError", {
- message: Schema.String,
- cause: Schema.optional(Schema.Defect),
-}) {}
-
interface PendingEntry {
info: Request
- deferred: Deferred.Deferred<Answer[]>
+ deferred: Deferred.Deferred<Answer[], RejectedError>
}
export namespace QuestionService {
@@ -96,10 +91,10 @@ export namespace QuestionService {
sessionID: SessionID
questions: Info[]
tool?: { messageID: MessageID; callID: string }
- }) => Effect.Effect<Answer[], QuestionServiceError>
- readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void, QuestionServiceError>
- readonly reject: (requestID: QuestionID) => Effect.Effect<void, QuestionServiceError>
- readonly list: () => Effect.Effect<Request[], QuestionServiceError>
+ }) => Effect.Effect<Answer[], RejectedError>
+ readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect<void>
+ readonly reject: (requestID: QuestionID) => Effect.Effect<void>
+ readonly list: () => Effect.Effect<Request[]>
}
}
@@ -109,7 +104,7 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
static readonly layer = Layer.effect(
QuestionService,
Effect.gen(function* () {
- const instanceState = yield* InstanceState.make<Map<QuestionID, PendingEntry>, QuestionServiceError>(() =>
+ const instanceState = yield* InstanceState.make<Map<QuestionID, PendingEntry>>(() =>
Effect.succeed(new Map<QuestionID, PendingEntry>()),
)
@@ -124,7 +119,7 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
const id = QuestionID.ascending()
log.info("asking", { id, questions: input.questions.length })
- const deferred = yield* Deferred.make<Answer[]>()
+ const deferred = yield* Deferred.make<Answer[], RejectedError>()
const info: Request = {
id,
sessionID: input.sessionID,
@@ -167,7 +162,7 @@ export class QuestionService extends ServiceMap.Service<QuestionService, Questio
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
- yield* Deferred.die(existing.deferred, new RejectedError())
+ yield* Deferred.fail(existing.deferred, new RejectedError)
})
const list = Effect.fn("QuestionService.list")(function* () {
diff --git a/packages/opencode/src/util/instance-state.ts b/packages/opencode/src/util/instance-state.ts
index 15cc3b714..4e5d36cf4 100644
--- a/packages/opencode/src/util/instance-state.ts
+++ b/packages/opencode/src/util/instance-state.ts
@@ -43,14 +43,16 @@ export namespace InstanceState {
})
/** Get the cached value for the current directory, initializing it if needed. */
- export const get = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.get(self.cache, Instance.directory)
+ export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
+ Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
/** Check whether a value exists for the current directory. */
- export const has = <A, E, R>(self: InstanceState<A, E, R>) => ScopedCache.has(self.cache, Instance.directory)
+ export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
+ Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
/** Invalidate the cached value for the current directory. */
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
- ScopedCache.invalidate(self.cache, Instance.directory)
+ Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
/** Invalidate the given directory across all InstanceState caches. */
export const dispose = (directory: string) =>
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index 7fd081899..cd4775ace 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -1,10 +1,32 @@
import { test, expect } from "bun:test"
import os from "os"
+import { Bus } from "../../src/bus"
+import { runtime } from "../../src/effect/runtime"
import { PermissionNext } from "../../src/permission/next"
+import * as S from "../../src/permission/service"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
-import { SessionID } from "../../src/session/schema"
+import { MessageID, SessionID } from "../../src/session/schema"
+
+async function rejectAll(message?: string) {
+ for (const req of await PermissionNext.list()) {
+ await PermissionNext.reply({
+ requestID: req.id,
+ reply: "reject",
+ message,
+ })
+ }
+}
+
+async function waitForPending(count: number) {
+ for (let i = 0; i < 20; i++) {
+ const list = await PermissionNext.list()
+ if (list.length === count) return list
+ await Bun.sleep(0)
+ }
+ return PermissionNext.list()
+}
// fromConfig tests
@@ -511,6 +533,84 @@ test("ask - returns pending promise when action is ask", async () => {
// Promise should be pending, not resolved
expect(promise).toBeInstanceOf(Promise)
// Don't await - just verify it returns a promise
+ await rejectAll()
+ await promise.catch(() => {})
+ },
+ })
+})
+
+test("ask - adds request to pending list", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ask = PermissionNext.ask({
+ sessionID: SessionID.make("session_test"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: { cmd: "ls" },
+ always: ["ls"],
+ tool: {
+ messageID: MessageID.make("msg_test"),
+ callID: "call_test",
+ },
+ ruleset: [],
+ })
+
+ const list = await PermissionNext.list()
+ expect(list).toHaveLength(1)
+ expect(list[0]).toMatchObject({
+ sessionID: SessionID.make("session_test"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: { cmd: "ls" },
+ always: ["ls"],
+ tool: {
+ messageID: MessageID.make("msg_test"),
+ callID: "call_test",
+ },
+ })
+
+ await rejectAll()
+ await ask.catch(() => {})
+ },
+ })
+})
+
+test("ask - publishes asked event", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ let seen: PermissionNext.Request | undefined
+ const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
+ seen = event.properties
+ })
+
+ const ask = PermissionNext.ask({
+ sessionID: SessionID.make("session_test"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: { cmd: "ls" },
+ always: ["ls"],
+ tool: {
+ messageID: MessageID.make("msg_test"),
+ callID: "call_test",
+ },
+ ruleset: [],
+ })
+
+ expect(await PermissionNext.list()).toHaveLength(1)
+ expect(seen).toBeDefined()
+ expect(seen).toMatchObject({
+ sessionID: SessionID.make("session_test"),
+ permission: "bash",
+ patterns: ["ls"],
+ })
+
+ unsub()
+ await rejectAll()
+ await ask.catch(() => {})
},
})
})
@@ -532,6 +632,8 @@ test("reply - once resolves the pending ask", async () => {
ruleset: [],
})
+ await waitForPending(1)
+
await PermissionNext.reply({
requestID: PermissionID.make("per_test1"),
reply: "once",
@@ -557,6 +659,8 @@ test("reply - reject throws RejectedError", async () => {
ruleset: [],
})
+ await waitForPending(1)
+
await PermissionNext.reply({
requestID: PermissionID.make("per_test2"),
reply: "reject",
@@ -567,6 +671,36 @@ test("reply - reject throws RejectedError", async () => {
})
})
+test("reply - reject with message throws CorrectedError", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ask = PermissionNext.ask({
+ id: PermissionID.make("per_test2b"),
+ sessionID: SessionID.make("session_test"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+
+ await waitForPending(1)
+
+ await PermissionNext.reply({
+ requestID: PermissionID.make("per_test2b"),
+ reply: "reject",
+ message: "Use a safer command",
+ })
+
+ const err = await ask.catch((err) => err)
+ expect(err).toBeInstanceOf(PermissionNext.CorrectedError)
+ expect(err.message).toContain("Use a safer command")
+ },
+ })
+})
+
test("reply - always persists approval and resolves", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
@@ -582,6 +716,8 @@ test("reply - always persists approval and resolves", async () => {
ruleset: [],
})
+ await waitForPending(1)
+
await PermissionNext.reply({
requestID: PermissionID.make("per_test3"),
reply: "always",
@@ -633,6 +769,8 @@ test("reply - reject cancels all pending for same session", async () => {
ruleset: [],
})
+ await waitForPending(2)
+
// Catch rejections before they become unhandled
const result1 = askPromise1.catch((e) => e)
const result2 = askPromise2.catch((e) => e)
@@ -650,6 +788,144 @@ test("reply - reject cancels all pending for same session", async () => {
})
})
+test("reply - always resolves matching pending requests in same session", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const a = PermissionNext.ask({
+ id: PermissionID.make("per_test5a"),
+ sessionID: SessionID.make("session_same"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: ["ls"],
+ ruleset: [],
+ })
+
+ const b = PermissionNext.ask({
+ id: PermissionID.make("per_test5b"),
+ sessionID: SessionID.make("session_same"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+
+ await waitForPending(2)
+
+ await PermissionNext.reply({
+ requestID: PermissionID.make("per_test5a"),
+ reply: "always",
+ })
+
+ await expect(a).resolves.toBeUndefined()
+ await expect(b).resolves.toBeUndefined()
+ expect(await PermissionNext.list()).toHaveLength(0)
+ },
+ })
+})
+
+test("reply - always keeps other session pending", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const a = PermissionNext.ask({
+ id: PermissionID.make("per_test6a"),
+ sessionID: SessionID.make("session_a"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: ["ls"],
+ ruleset: [],
+ })
+
+ const b = PermissionNext.ask({
+ id: PermissionID.make("per_test6b"),
+ sessionID: SessionID.make("session_b"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+
+ await waitForPending(2)
+
+ await PermissionNext.reply({
+ requestID: PermissionID.make("per_test6a"),
+ reply: "always",
+ })
+
+ await expect(a).resolves.toBeUndefined()
+ expect((await PermissionNext.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
+
+ await rejectAll()
+ await b.catch(() => {})
+ },
+ })
+})
+
+test("reply - publishes replied event", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ask = PermissionNext.ask({
+ id: PermissionID.make("per_test7"),
+ sessionID: SessionID.make("session_test"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ })
+
+ await waitForPending(1)
+
+ let seen:
+ | {
+ sessionID: SessionID
+ requestID: PermissionID
+ reply: PermissionNext.Reply
+ }
+ | undefined
+ const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
+ seen = event.properties
+ })
+
+ await PermissionNext.reply({
+ requestID: PermissionID.make("per_test7"),
+ reply: "once",
+ })
+
+ await expect(ask).resolves.toBeUndefined()
+ expect(seen).toEqual({
+ sessionID: SessionID.make("session_test"),
+ requestID: PermissionID.make("per_test7"),
+ reply: "once",
+ })
+ unsub()
+ },
+ })
+})
+
+test("reply - does nothing for unknown requestID", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await PermissionNext.reply({
+ requestID: PermissionID.make("per_unknown"),
+ reply: "once",
+ })
+ expect(await PermissionNext.list()).toHaveLength(0)
+ },
+ })
+})
+
test("ask - checks all patterns and stops on first deny", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
@@ -689,3 +965,74 @@ test("ask - allows all patterns when all match allow rules", async () => {
},
})
})
+
+test("ask - should deny even when an earlier pattern is ask", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ask = PermissionNext.ask({
+ sessionID: SessionID.make("session_test"),
+ permission: "bash",
+ patterns: ["echo hello", "rm -rf /"],
+ metadata: {},
+ always: [],
+ ruleset: [
+ { permission: "bash", pattern: "echo *", action: "ask" },
+ { permission: "bash", pattern: "rm *", action: "deny" },
+ ],
+ })
+
+ const out = await Promise.race([
+ ask.then(
+ () => ({ ok: true as const, err: undefined }),
+ (err) => ({ ok: false as const, err }),
+ ),
+ Bun.sleep(100).then(() => "timeout" as const),
+ ])
+
+ if (out === "timeout") {
+ await rejectAll()
+ await ask.catch(() => {})
+ throw new Error("ask timed out instead of denying immediately")
+ }
+
+ expect(out.ok).toBe(false)
+ expect(out.err).toBeInstanceOf(PermissionNext.DeniedError)
+ expect(await PermissionNext.list()).toHaveLength(0)
+ },
+ })
+})
+
+test("ask - abort should clear pending request", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const ctl = new AbortController()
+ const ask = runtime.runPromise(
+ S.PermissionService.use((svc) =>
+ svc.ask({
+ sessionID: SessionID.make("session_test"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
+ }),
+ ),
+ { signal: ctl.signal },
+ )
+
+ await waitForPending(1)
+ ctl.abort()
+ await ask.catch(() => {})
+
+ try {
+ expect(await PermissionNext.list()).toHaveLength(0)
+ } finally {
+ await rejectAll()
+ }
+ },
+ })
+})
diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts
index 7659d690c..0761a9304 100644
--- a/packages/opencode/test/tool/read.test.ts
+++ b/packages/opencode/test/tool/read.test.ts
@@ -183,7 +183,7 @@ describe("tool.read env file permissions", () => {
askedForEnv = true
}
if (rule.action === "deny") {
- throw new PermissionNext.DeniedError(agent.permission)
+ throw new PermissionNext.DeniedError({ ruleset: agent.permission })
}
}
},
diff --git a/packages/opencode/test/util/instance-state.test.ts b/packages/opencode/test/util/instance-state.test.ts
index 29d0738c1..976b7d07e 100644
--- a/packages/opencode/test/util/instance-state.test.ts
+++ b/packages/opencode/test/util/instance-state.test.ts
@@ -1,5 +1,5 @@
import { afterEach, expect, test } from "bun:test"
-import { Effect } from "effect"
+import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
import { Instance } from "../../src/project/instance"
import { InstanceState } from "../../src/util/instance-state"
@@ -114,6 +114,129 @@ test("InstanceState is disposed on disposeAll", async () => {
)
})
+test("InstanceState.get reads correct directory per-evaluation (not captured once)", async () => {
+ await using a = await tmpdir()
+ await using b = await tmpdir()
+
+ // Regression: InstanceState.get must be lazy (Effect.suspend) so the
+ // directory is read per-evaluation, not captured once at the call site.
+ // Without this, a service built inside a ManagedRuntime Layer would
+ // freeze to whichever directory triggered the first layer build.
+
+ interface TestApi {
+ readonly getDir: () => Effect.Effect<string>
+ }
+
+ class TestService extends ServiceMap.Service<TestService, TestApi>()("@test/ALS-lazy") {
+ static readonly layer = Layer.effect(
+ TestService,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make((dir) => Effect.sync(() => dir))
+ // `get` is created once during layer build — must be lazy
+ const get = InstanceState.get(state)
+
+ const getDir = Effect.fn("TestService.getDir")(function* () {
+ return yield* get
+ })
+
+ return TestService.of({ getDir })
+ }),
+ )
+ }
+
+ const rt = ManagedRuntime.make(TestService.layer)
+
+ try {
+ const resultA = await Instance.provide({
+ directory: a.path,
+ fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
+ })
+ expect(resultA).toBe(a.path)
+
+ // Second call with different directory must NOT return A's directory
+ const resultB = await Instance.provide({
+ directory: b.path,
+ fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
+ })
+ expect(resultB).toBe(b.path)
+ } finally {
+ await rt.dispose()
+ }
+})
+
+test("InstanceState.get isolates concurrent fibers across real delays, yields, and timer callbacks", async () => {
+ await using a = await tmpdir()
+ await using b = await tmpdir()
+ await using c = await tmpdir()
+
+ // Adversarial: concurrent fibers with real timer delays (macrotask
+ // boundaries via setTimeout/Bun.sleep), explicit scheduler yields,
+ // and many async steps. If ALS context leaks or gets lost at any
+ // point, a fiber will see the wrong directory.
+
+ interface TestApi {
+ readonly getDir: () => Effect.Effect<string>
+ }
+
+ class TestService extends ServiceMap.Service<TestService, TestApi>()("@test/ALS-adversarial") {
+ static readonly layer = Layer.effect(
+ TestService,
+ Effect.gen(function* () {
+ const state = yield* InstanceState.make((dir) => Effect.sync(() => dir))
+
+ const getDir = Effect.fn("TestService.getDir")(function* () {
+ // Mix of async boundary types to maximise interleaving:
+ // 1. Real timer delay (macrotask — setTimeout under the hood)
+ yield* Effect.promise(() => Bun.sleep(1))
+ // 2. Effect.sleep (Effect's own timer, uses its internal scheduler)
+ yield* Effect.sleep(Duration.millis(1))
+ // 3. Explicit scheduler yields
+ for (let i = 0; i < 100; i++) {
+ yield* Effect.yieldNow
+ }
+ // 4. Microtask boundaries
+ for (let i = 0; i < 100; i++) {
+ yield* Effect.promise(() => Promise.resolve())
+ }
+ // 5. Another Effect.sleep
+ yield* Effect.sleep(Duration.millis(2))
+ // 6. Another real timer to force a second macrotask hop
+ yield* Effect.promise(() => Bun.sleep(1))
+ // NOW read the directory — ALS must still be correct
+ return yield* InstanceState.get(state)
+ })
+
+ return TestService.of({ getDir })
+ }),
+ )
+ }
+
+ const rt = ManagedRuntime.make(TestService.layer)
+
+ try {
+ const [resultA, resultB, resultC] = await Promise.all([
+ Instance.provide({
+ directory: a.path,
+ fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
+ }),
+ Instance.provide({
+ directory: b.path,
+ fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
+ }),
+ Instance.provide({
+ directory: c.path,
+ fn: () => rt.runPromise(TestService.use((s) => s.getDir())),
+ }),
+ ])
+
+ expect(resultA).toBe(a.path)
+ expect(resultB).toBe(b.path)
+ expect(resultC).toBe(c.path)
+ } finally {
+ await rt.dispose()
+ }
+})
+
test("InstanceState dedupes concurrent lookups for the same directory", async () => {
await using tmp = await tmpdir()
let n = 0