diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index a45aaf59d5..dc22d32b4b 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -2,7 +2,6 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { ProjectID } from "@/project/schema" import { Instance } from "@/project/instance" import { MessageID, SessionID } from "@/session/schema" @@ -308,18 +307,4 @@ export namespace Permission { } export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) - - export const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function ask(input: z.infer) { - return runPromise((s) => s.ask(input)) - } - - export async function reply(input: z.infer) { - return runPromise((s) => s.reply(input)) - } - - export async function list() { - return runPromise((s) => s.list()) - } } diff --git a/packages/opencode/src/server/instance/permission.ts b/packages/opencode/src/server/instance/permission.ts index aae9a9c3a6..3f93709354 100644 --- a/packages/opencode/src/server/instance/permission.ts +++ b/packages/opencode/src/server/instance/permission.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import { AppRuntime } from "@/effect/app-runtime" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { errors } from "../error" @@ -36,11 +37,15 @@ export const PermissionRoutes = lazy(() => async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") - await Permission.reply({ - requestID: params.requestID, - reply: json.reply, - message: json.message, - }) + await AppRuntime.runPromise( + Permission.Service.use((svc) => + svc.reply({ + requestID: params.requestID, + reply: json.reply, + message: json.message, + }), + ), + ) return c.json(true) }, ) @@ -62,7 +67,7 @@ export const PermissionRoutes = lazy(() => }, }), async (c) => { - const permissions = await Permission.list() + const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) return c.json(permissions) }, ), diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 32bd3d9fc8..86d6a8ef42 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -1070,10 +1070,14 @@ export const SessionRoutes = lazy(() => validator("json", z.object({ response: Permission.Reply })), async (c) => { const params = c.req.valid("param") - Permission.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }) + await AppRuntime.runPromise( + Permission.Service.use((svc) => + svc.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }), + ), + ) return c.json(true) }, ), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c3607e1770..8df70b6739 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -21,6 +21,7 @@ import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" import { Installation } from "@/installation" +import { AppRuntime } from "@/effect/app-runtime" export namespace LLM { const log = Log.create({ service: "llm" }) @@ -305,15 +306,19 @@ export namespace LLM { } }) const uniquePatterns = [...new Set(toolPatterns)] as string[] - await Permission.ask({ - id, - sessionID: SessionID.make(input.sessionID), - permission: "workflow_tool_approval", - patterns: uniquePatterns, - metadata: { tools: approvalTools }, - always: uniquePatterns, - ruleset: [], - }) + await AppRuntime.runPromise( + Permission.Service.use((svc) => + svc.ask({ + id, + sessionID: SessionID.make(input.sessionID), + permission: "workflow_tool_approval", + patterns: uniquePatterns, + metadata: { tools: approvalTools }, + always: uniquePatterns, + ruleset: [], + }), + ), + ) for (const name of uniqueNames) approvedToolsForSession.add(name) workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames] return { approved: true } diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 043e3257b6..9e3007f6dc 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,33 +1,77 @@ import { afterEach, test, expect } from "bun:test" import os from "os" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Bus } from "../../src/bus" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdir, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" +const bus = Bus.layer +const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) +const it = testEffect(env) + afterEach(async () => { await Instance.disposeAll() }) -async function rejectAll(message?: string) { - for (const req of await Permission.list()) { - await Permission.reply({ - requestID: req.id, - reply: "reject", - message, - }) - } +const rejectAll = (message?: string) => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (const req of yield* permission.list()) { + yield* permission.reply({ + requestID: req.id, + reply: "reject", + message, + }) + } + }) + +const waitForPending = (count: number) => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (let i = 0; i < 100; i++) { + const list = yield* permission.list() + if (list.length === count) return list + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending permission request(s)`)) + }) + +const fail = (self: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* self.pipe(Effect.exit) + if (Exit.isFailure(exit)) return Cause.squash(exit.cause) + throw new Error("expected permission effect to fail") + }) + +const ask = (input: Parameters[0]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.ask(input) + }) + +const reply = (input: Parameters[0]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.reply(input) + }) + +const list = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.list() + }) + +function withDir(options: { git?: boolean } | undefined, self: (dir: string) => Effect.Effect) { + return provideTmpdirInstance(self, options) } -async function waitForPending(count: number) { - for (let i = 0; i < 20; i++) { - const list = await Permission.list() - if (list.length === count) return list - await Bun.sleep(0) - } - return Permission.list() +function withProvided(dir: string) { + return (self: Effect.Effect) => self.pipe(provideInstance(dir)) } // fromConfig tests @@ -170,24 +214,19 @@ test("merge - preserves rule order", () => { }) test("merge - config permission overrides default ask", () => { - // Simulates: defaults have "*": "ask", config sets bash: "allow" const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const merged = Permission.merge(defaults, config) - // Config's bash allow should override default ask expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow") - // Other permissions should still be ask (from defaults) expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask") }) test("merge - config ask overrides default allow", () => { - // Simulates: defaults have bash: "allow", config sets bash: "ask" const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] const merged = Permission.merge(defaults, config) - // Config's ask should override default allow expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask") }) @@ -233,7 +272,6 @@ test("evaluate - last matching glob wins", () => { }) test("evaluate - order matters for specificity", () => { - // If more specific rule comes first, later wildcard overrides it const result = Permission.evaluate("edit", "src/components/Button.tsx", [ { permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "deny" }, @@ -350,19 +388,16 @@ test("evaluate - wildcard permission fallback for unknown tool", () => { }) test("evaluate - permission patterns sorted by length regardless of object order", () => { - // specific permission listed before wildcard, but specific should still win const result = Permission.evaluate("bash", "rm", [ { permission: "bash", pattern: "*", action: "allow" }, { permission: "*", pattern: "*", action: "deny" }, ]) - // With flat list, last matching rule wins - so "*" matches bash and wins expect(result.action).toBe("deny") }) test("evaluate - merges multiple rulesets", () => { const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] - // approved comes after config, so rm should be denied const result = Permission.evaluate("bash", "rm", config, approved) expect(result.action).toBe("deny") }) @@ -419,8 +454,6 @@ test("disabled - does not disable when action is ask", () => { }) test("disabled - does not disable when specific allow after wildcard deny", () => { - // Tool is NOT disabled because a specific allow after wildcard deny means - // there's at least some usage allowed const result = Permission.disabled( ["bash"], [ @@ -478,12 +511,10 @@ test("disabled - specific allow overrides wildcard deny", () => { // ask tests -test("ask - resolves immediately when action is allow", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await Permission.ask({ +it.live("ask - resolves immediately when action is allow", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const result = yield* ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -492,17 +523,15 @@ test("ask - resolves immediately when action is allow", async () => { ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], }) expect(result).toBeUndefined() - }, - }) -}) + }), + ), +) -test("ask - throws RejectedError when action is deny", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect( - Permission.ask({ +it.live("ask - throws DeniedError when action is deny", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const err = yield* fail( + ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["rm -rf /"], @@ -510,39 +539,35 @@ test("ask - throws RejectedError when action is deny", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], }), - ).rejects.toBeInstanceOf(Permission.DeniedError) - }, - }) -}) + ) + expect(err).toBeInstanceOf(Permission.DeniedError) + }), + ), +) -test("ask - returns pending promise when action is ask", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = Permission.ask({ +it.live("ask - stays pending when action is ask", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], metadata: {}, always: [], ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], - }) - // Promise should be pending, not resolved - expect(promise).toBeInstanceOf(Promise) - // Don't await - just verify it returns a promise - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) + }).pipe(Effect.forkScoped) -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 = Permission.ask({ + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + ), +) + +it.live("ask - adds request to pending list", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -553,11 +578,11 @@ test("ask - adds request to pending list", async () => { callID: "call_test", }, ruleset: [], - }) + }).pipe(Effect.forkScoped) - const list = await Permission.list() - expect(list).toHaveLength(1) - expect(list[0]).toMatchObject({ + const items = yield* waitForPending(1) + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -569,58 +594,58 @@ test("ask - adds request to pending list", async () => { }, }) - await rejectAll() - await ask.catch(() => {}) - }, - }) -}) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + ), +) -test("ask - publishes asked event", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { +it.live("ask - publishes asked event", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const bus = yield* Bus.Service let seen: Permission.Request | undefined - const unsub = Bus.subscribe(Permission.Event.Asked, (event) => { + const unsub = yield* bus.subscribeCallback(Permission.Event.Asked, (event) => { seen = event.properties }) - const ask = Permission.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: [], - }) + try { + const fiber = yield* 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: [], + }).pipe(Effect.forkScoped) - expect(await Permission.list()).toHaveLength(1) - expect(seen).toBeDefined() - expect(seen).toMatchObject({ - sessionID: SessionID.make("session_test"), - permission: "bash", - patterns: ["ls"], - }) + expect(yield* waitForPending(1)).toHaveLength(1) + expect(seen).toBeDefined() + expect(seen).toMatchObject({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + }) - unsub() - await rejectAll() - await ask.catch(() => {}) - }, - }) -}) + yield* rejectAll() + yield* Fiber.await(fiber) + } finally { + unsub() + } + }), + ), +) // reply tests -test("reply - once resolves the pending ask", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const askPromise = Permission.ask({ +it.live("reply - once resolves the pending ask", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ id: PermissionID.make("per_test1"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -628,26 +653,19 @@ test("reply - once resolves the pending ask", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(1) + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" }) + yield* Fiber.join(fiber) + }), + ), +) - await Permission.reply({ - requestID: PermissionID.make("per_test1"), - reply: "once", - }) - - await expect(askPromise).resolves.toBeUndefined() - }, - }) -}) - -test("reply - reject throws RejectedError", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const askPromise = Permission.ask({ +it.live("reply - reject throws RejectedError", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ id: PermissionID.make("per_test2"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -655,26 +673,22 @@ test("reply - reject throws RejectedError", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(1) + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" }) - await Permission.reply({ - requestID: PermissionID.make("per_test2"), - reply: "reject", - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + }), + ), +) - await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError) - }, - }) -}) - -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 = Permission.ask({ +it.live("reply - reject with message throws CorrectedError", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ id: PermissionID.make("per_test2b"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -682,72 +696,60 @@ test("reply - reject with message throws CorrectedError", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(1) - - await Permission.reply({ + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("per_test2b"), reply: "reject", message: "Use a safer command", }) - const err = await ask.catch((err) => err) - expect(err).toBeInstanceOf(Permission.CorrectedError) - expect(err.message).toContain("Use a safer command") - }, - }) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err).toBeInstanceOf(Permission.CorrectedError) + expect(String(err)).toContain("Use a safer command") + } + }), + ), +) -test("reply - always persists approval and resolves", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const askPromise = Permission.ask({ - id: PermissionID.make("per_test3"), - sessionID: SessionID.make("session_test"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: ["ls"], - ruleset: [], - }) +it.live("reply - always persists approval and resolves", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) + const fiber = yield* ask({ + id: PermissionID.make("per_test3"), + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: ["ls"], + ruleset: [], + }).pipe(run, Effect.forkScoped) - await waitForPending(1) + yield* waitForPending(1).pipe(run) + yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" }).pipe(run) + yield* Fiber.join(fiber) - await Permission.reply({ - requestID: PermissionID.make("per_test3"), - reply: "always", - }) + const result = yield* ask({ + sessionID: SessionID.make("session_test2"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(run) + expect(result).toBeUndefined() + }), +) - await expect(askPromise).resolves.toBeUndefined() - }, - }) - // Re-provide to reload state with stored permissions - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Stored approval should allow without asking - const result = await Permission.ask({ - sessionID: SessionID.make("session_test2"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }) - expect(result).toBeUndefined() - }, - }) -}) - -test("reply - reject cancels all pending for same session", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const askPromise1 = Permission.ask({ +it.live("reply - reject cancels all pending for same session", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const a = yield* ask({ id: PermissionID.make("per_test4a"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -755,9 +757,9 @@ test("reply - reject cancels all pending for same session", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - const askPromise2 = Permission.ask({ + const b = yield* ask({ id: PermissionID.make("per_test4b"), sessionID: SessionID.make("session_same"), permission: "edit", @@ -765,33 +767,24 @@ test("reply - reject cancels all pending for same session", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(2) + yield* waitForPending(2) + yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" }) - // Catch rejections before they become unhandled - const result1 = askPromise1.catch((e) => e) - const result2 = askPromise2.catch((e) => e) + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isFailure(ea)).toBe(true) + expect(Exit.isFailure(eb)).toBe(true) + if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError) + if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(Permission.RejectedError) + }), + ), +) - // Reject the first one - await Permission.reply({ - requestID: PermissionID.make("per_test4a"), - reply: "reject", - }) - - // Both should be rejected - expect(await result1).toBeInstanceOf(Permission.RejectedError) - expect(await result2).toBeInstanceOf(Permission.RejectedError) - }, - }) -}) - -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 = Permission.ask({ +it.live("reply - always resolves matching pending requests in same session", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const a = yield* ask({ id: PermissionID.make("per_test5a"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -799,9 +792,9 @@ test("reply - always resolves matching pending requests in same session", async metadata: {}, always: ["ls"], ruleset: [], - }) + }).pipe(Effect.forkScoped) - const b = Permission.ask({ + const b = yield* ask({ id: PermissionID.make("per_test5b"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -809,28 +802,22 @@ test("reply - always resolves matching pending requests in same session", async metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(2) + yield* waitForPending(2) + yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" }) - await Permission.reply({ - requestID: PermissionID.make("per_test5a"), - reply: "always", - }) + yield* Fiber.join(a) + yield* Fiber.join(b) + expect(yield* list()).toHaveLength(0) + }), + ), +) - await expect(a).resolves.toBeUndefined() - await expect(b).resolves.toBeUndefined() - expect(await Permission.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 = Permission.ask({ +it.live("reply - always keeps other session pending", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const a = yield* ask({ id: PermissionID.make("per_test6a"), sessionID: SessionID.make("session_a"), permission: "bash", @@ -838,9 +825,9 @@ test("reply - always keeps other session pending", async () => { metadata: {}, always: ["ls"], ruleset: [], - }) + }).pipe(Effect.forkScoped) - const b = Permission.ask({ + const b = yield* ask({ id: PermissionID.make("per_test6b"), sessionID: SessionID.make("session_b"), permission: "bash", @@ -848,30 +835,37 @@ test("reply - always keeps other session pending", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(2) + yield* waitForPending(2) + yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" }) - await Permission.reply({ - requestID: PermissionID.make("per_test6a"), - reply: "always", - }) + yield* Fiber.join(a) + expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")]) - await expect(a).resolves.toBeUndefined() - expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) + yield* rejectAll() + yield* Fiber.await(b) + }), + ), +) - await rejectAll() - await b.catch(() => {}) - }, - }) -}) +it.live("reply - publishes replied event", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const bus = yield* Bus.Service + let resolve!: (value: { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }) => void + const seen = Effect.promise<{ + sessionID: SessionID + requestID: PermissionID + reply: Permission.Reply + }>( + () => + new Promise((res) => { + resolve = res + }), + ) -test("reply - publishes replied event", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ask = Permission.ask({ + const fiber = yield* ask({ id: PermissionID.make("per_test7"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -879,183 +873,132 @@ test("reply - publishes replied event", async () => { metadata: {}, always: [], ruleset: [], + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + + const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => { + resolve(event.properties) }) - await waitForPending(1) + try { + yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" }) + yield* Fiber.join(fiber) + expect(yield* seen).toEqual({ + sessionID: SessionID.make("session_test"), + requestID: PermissionID.make("per_test7"), + reply: "once", + }) + } finally { + unsub() + } + }), + ), +) - let seen: - | { - sessionID: SessionID - requestID: PermissionID - reply: Permission.Reply - } - | undefined - const unsub = Bus.subscribe(Permission.Event.Replied, (event) => { - seen = event.properties - }) +it.live("permission requests stay isolated by directory", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped({ git: true }) + const two = yield* tmpdirScoped({ git: true }) + const runOne = withProvided(one) + const runTwo = withProvided(two) - await Permission.reply({ - requestID: PermissionID.make("per_test7"), - reply: "once", - }) + const a = yield* ask({ + id: PermissionID.make("per_dir_a"), + sessionID: SessionID.make("session_dir_a"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(runOne, Effect.forkScoped) - await expect(ask).resolves.toBeUndefined() - expect(seen).toEqual({ - sessionID: SessionID.make("session_test"), - requestID: PermissionID.make("per_test7"), - reply: "once", - }) - unsub() - }, - }) -}) + const b = yield* ask({ + id: PermissionID.make("per_dir_b"), + sessionID: SessionID.make("session_dir_b"), + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(runTwo, Effect.forkScoped) -test("permission requests stay isolated by directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) + const onePending = yield* waitForPending(1).pipe(runOne) + const twoPending = yield* waitForPending(1).pipe(runTwo) - const a = Instance.provide({ - directory: one.path, - fn: () => - Permission.ask({ - id: PermissionID.make("per_dir_a"), - sessionID: SessionID.make("session_dir_a"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }), - }) + expect(onePending).toHaveLength(1) + expect(twoPending).toHaveLength(1) + expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) + expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b")) - const b = Instance.provide({ - directory: two.path, - fn: () => - Permission.ask({ - id: PermissionID.make("per_dir_b"), - sessionID: SessionID.make("session_dir_b"), - permission: "bash", - patterns: ["pwd"], - metadata: {}, - always: [], - ruleset: [], - }), - }) + yield* reply({ requestID: onePending[0].id, reply: "reject" }).pipe(runOne) + yield* reply({ requestID: twoPending[0].id, reply: "reject" }).pipe(runTwo) - const onePending = await Instance.provide({ - directory: one.path, - fn: () => waitForPending(1), - }) - const twoPending = await Instance.provide({ - directory: two.path, - fn: () => waitForPending(1), - }) + yield* Fiber.await(a) + yield* Fiber.await(b) + }), +) - expect(onePending).toHaveLength(1) - expect(twoPending).toHaveLength(1) - expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) - expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b")) +it.live("pending permission rejects on instance dispose", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) + const fiber = yield* ask({ + id: PermissionID.make("per_dispose"), + sessionID: SessionID.make("session_dispose"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(run, Effect.forkScoped) - await Instance.provide({ - directory: one.path, - fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }), - }) - await Instance.provide({ - directory: two.path, - fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }), - }) + expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) + yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })) - await a.catch(() => {}) - await b.catch(() => {}) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + }), +) -test("pending permission rejects on instance dispose", async () => { - await using tmp = await tmpdir({ git: true }) +it.live("pending permission rejects on instance reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) + const fiber = yield* ask({ + id: PermissionID.make("per_reload"), + sessionID: SessionID.make("session_reload"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(run, Effect.forkScoped) - const ask = Instance.provide({ - directory: tmp.path, - fn: () => - Permission.ask({ - id: PermissionID.make("per_dispose"), - sessionID: SessionID.make("session_dispose"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }), - }) - const result = ask.then( - () => "resolved" as const, - (err) => err, - ) + expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) + yield* Effect.promise(() => Instance.reload({ directory: dir })) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await waitForPending(1) - expect(pending).toHaveLength(1) - await Instance.dispose() - }, - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + }), +) - expect(await result).toBeInstanceOf(Permission.RejectedError) -}) +it.live("reply - does nothing for unknown requestID", () => + withDir({ git: true }, () => + Effect.gen(function* () { + yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }) + expect(yield* list()).toHaveLength(0) + }), + ), +) -test("pending permission rejects on instance reload", async () => { - await using tmp = await tmpdir({ git: true }) - - const ask = Instance.provide({ - directory: tmp.path, - fn: () => - Permission.ask({ - id: PermissionID.make("per_reload"), - sessionID: SessionID.make("session_reload"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }), - }) - const result = ask.then( - () => "resolved" as const, - (err) => err, - ) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await waitForPending(1) - expect(pending).toHaveLength(1) - await Instance.reload({ directory: tmp.path }) - }, - }) - - expect(await result).toBeInstanceOf(Permission.RejectedError) -}) - -test("reply - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Permission.reply({ - requestID: PermissionID.make("per_unknown"), - reply: "once", - }) - expect(await Permission.list()).toHaveLength(0) - }, - }) -}) - -test("ask - checks all patterns and stops on first deny", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect( - Permission.ask({ +it.live("ask - checks all patterns and stops on first deny", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const err = yield* fail( + ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "rm -rf /"], @@ -1066,17 +1009,16 @@ test("ask - checks all patterns and stops on first deny", async () => { { permission: "bash", pattern: "rm *", action: "deny" }, ], }), - ).rejects.toBeInstanceOf(Permission.DeniedError) - }, - }) -}) + ) + expect(err).toBeInstanceOf(Permission.DeniedError) + }), + ), +) -test("ask - allows all patterns when all match allow rules", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await Permission.ask({ +it.live("ask - allows all patterns when all match allow rules", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const result = yield* ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "ls -la", "pwd"], @@ -1085,64 +1027,54 @@ test("ask - allows all patterns when all match allow rules", async () => { ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], }) expect(result).toBeUndefined() - }, - }) -}) + }), + ), +) -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 err = await Permission.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" }, - ], - }).then( - () => undefined, - (err) => err, +it.live("ask - should deny even when an earlier pattern is ask", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const err = yield* fail( + 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" }, + ], + }), ) expect(err).toBeInstanceOf(Permission.DeniedError) - expect(await Permission.list()).toHaveLength(0) - }, - }) -}) + expect(yield* 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 = Permission.runPromise( - (svc) => - svc.ask({ - sessionID: SessionID.make("session_test"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], - }), - { signal: ctl.signal }, - ) +it.live("ask - abort should clear pending request", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) - await waitForPending(1) - ctl.abort() - await ask.catch(() => {}) + const fiber = yield* ask({ + id: PermissionID.make("per_reload"), + sessionID: SessionID.make("session_reload"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }).pipe(run, Effect.forkScoped) - try { - expect(await Permission.list()).toHaveLength(0) - } finally { - await rejectAll() - } - }, - }) -}) + const pending = yield* waitForPending(1).pipe(run) + expect(pending).toHaveLength(1) + yield* Effect.promise(() => Instance.reload({ directory: dir })) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + }), +)