Procházet zdrojové kódy

refactor(permission): effectify PermissionNext + fix InstanceState ALS bug (#17511)

Kit Langton před 1 měsícem
rodič
revize
f015154314

+ 1 - 1
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 })
         }
       }
     },

+ 2 - 1
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),
 )

+ 1 - 1
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: {

+ 36 - 228
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)
-  }
 }

+ 10 - 10
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>
+}

+ 265 - 0
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: "*" }
+}

+ 1 - 1
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))
 }
 

+ 11 - 16
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* () {

+ 5 - 3
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) =>

+ 348 - 1
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()
+      }
+    },
+  })
+})

+ 1 - 1
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 })
                 }
               }
             },

+ 124 - 1
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