diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts index f8b8b6e460..2782c0aba1 100644 --- a/packages/opencode/src/permission/service.ts +++ b/packages/opencode/src/permission/service.ts @@ -148,6 +148,7 @@ export class PermissionService extends ServiceMap.Service) { 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) @@ -158,19 +159,27 @@ export class PermissionService extends ServiceMap.Service() - state.pending.set(id, { info, deferred }) - void Bus.publish(Event.Asked, info) - return yield* Deferred.await(deferred) + 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() + 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) { @@ -188,7 +197,7 @@ export class PermissionService extends ServiceMap.Service { }, }) }) + +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() + } + }, + }) +})