summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-13 19:33:58 -0400
committerGitHub <[email protected]>2026-04-13 19:33:58 -0400
commitd199648aebd68cbd95b8af0afd59e608c9c18136 (patch)
treee02817c27d14c0b9561c5a08f2d7c7eb4559aaaa /packages
parenta06f40297b06e3ce39c0618f4347db34074003f7 (diff)
downloadopencode-d199648aebd68cbd95b8af0afd59e608c9c18136.tar.gz
opencode-d199648aebd68cbd95b8af0afd59e608c9c18136.zip
refactor(permission): remove async facade exports (#22342)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/permission/index.ts15
-rw-r--r--packages/opencode/src/server/instance/permission.ts17
-rw-r--r--packages/opencode/src/server/instance/session.ts12
-rw-r--r--packages/opencode/src/session/llm.ts23
-rw-r--r--packages/opencode/test/permission/next.test.ts1008
5 files changed, 503 insertions, 572 deletions
diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts
index a45aaf59d..dc22d32b4 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<typeof AskInput>) {
- return runPromise((s) => s.ask(input))
- }
-
- export async function reply(input: z.infer<typeof ReplyInput>) {
- 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 aae9a9c3a..3f9370935 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 32bd3d9fc..86d6a8ef4 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 c3607e177..8df70b673 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 043e3257b..9e3007f6d 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 = <A, E, R>(self: Effect.Effect<A, E, R>) =>
+ 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<Permission.Interface["ask"]>[0]) =>
+ Effect.gen(function* () {
+ const permission = yield* Permission.Service
+ return yield* permission.ask(input)
+ })
+
+const reply = (input: Parameters<Permission.Interface["reply"]>[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<any, any, any>) {
+ 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 <A, E, R>(self: Effect.Effect<A, E, R>) => 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)
- },
- })
-})
-
-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({
+ )
+ expect(err).toBeInstanceOf(Permission.DeniedError)
+ }),
+ ),
+)
+
+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(() => {})
- },
- })
-})
-
-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({
+ }).pipe(Effect.forkScoped)
+
+ 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: [],
- })
-
- expect(await Permission.list()).toHaveLength(1)
- expect(seen).toBeDefined()
- expect(seen).toMatchObject({
- sessionID: SessionID.make("session_test"),
- permission: "bash",
- patterns: ["ls"],
- })
+ 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(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: [],
- })
-
- await waitForPending(1)
-
- 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({
+ }).pipe(Effect.forkScoped)
+
+ yield* waitForPending(1)
+ yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" })
+ yield* Fiber.join(fiber)
+ }),
+ ),
+)
+
+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: [],
- })
-
- await waitForPending(1)
-
- await Permission.reply({
- requestID: PermissionID.make("per_test2"),
- reply: "reject",
- })
-
- 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({
+ }).pipe(Effect.forkScoped)
+
+ yield* waitForPending(1)
+ yield* 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)
+ }),
+ ),
+)
+
+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")
- },
- })
-})
-
-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: [],
- })
-
- await waitForPending(1)
-
- await Permission.reply({
- requestID: PermissionID.make("per_test3"),
- reply: "always",
- })
-
- 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({
+ 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")
+ }
+ }),
+ ),
+)
+
+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)
+
+ yield* waitForPending(1).pipe(run)
+ yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" }).pipe(run)
+ yield* Fiber.join(fiber)
+
+ const result = yield* ask({
+ sessionID: SessionID.make("session_test2"),
+ permission: "bash",
+ patterns: ["ls"],
+ metadata: {},
+ always: [],
+ ruleset: [],
+ }).pipe(run)
+ expect(result).toBeUndefined()
+ }),
+)
+
+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: [],
- })
-
- await waitForPending(2)
-
- // Catch rejections before they become unhandled
- const result1 = askPromise1.catch((e) => e)
- const result2 = askPromise2.catch((e) => e)
-
- // 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({
+ }).pipe(Effect.forkScoped)
+
+ yield* waitForPending(2)
+ yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" })
+
+ 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)
+ }),
+ ),
+)
+
+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: [],
- })
-
- await waitForPending(2)
-
- await Permission.reply({
- requestID: PermissionID.make("per_test5a"),
- reply: "always",
- })
-
- 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({
+ }).pipe(Effect.forkScoped)
+
+ yield* waitForPending(2)
+ yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" })
+
+ yield* Fiber.join(a)
+ yield* Fiber.join(b)
+ expect(yield* list()).toHaveLength(0)
+ }),
+ ),
+)
+
+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: [],
- })
-
- await waitForPending(2)
-
- await Permission.reply({
- requestID: PermissionID.make("per_test6a"),
- reply: "always",
- })
-
- await expect(a).resolves.toBeUndefined()
- expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
-
- await rejectAll()
- await b.catch(() => {})
- },
- })
-})
+ }).pipe(Effect.forkScoped)
+
+ yield* waitForPending(2)
+ yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" })
+
+ yield* Fiber.join(a)
+ expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")])
+
+ yield* rejectAll()
+ yield* Fiber.await(b)
+ }),
+ ),
+)
+
+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)
- await waitForPending(1)
-
- let seen:
- | {
- sessionID: SessionID
- requestID: PermissionID
- reply: Permission.Reply
- }
- | undefined
- const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
- seen = event.properties
- })
-
- await Permission.reply({
- requestID: PermissionID.make("per_test7"),
- reply: "once",
- })
+ yield* waitForPending(1)
- await expect(ask).resolves.toBeUndefined()
- expect(seen).toEqual({
- sessionID: SessionID.make("session_test"),
- requestID: PermissionID.make("per_test7"),
- reply: "once",
+ const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => {
+ resolve(event.properties)
})
- unsub()
- },
- })
-})
-
-test("permission requests stay isolated by directory", async () => {
- await using one = await tmpdir({ git: true })
- await using two = await tmpdir({ git: true })
-
- 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: [],
- }),
- })
- 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: [],
- }),
- })
-
- const onePending = await Instance.provide({
- directory: one.path,
- fn: () => waitForPending(1),
- })
- const twoPending = await Instance.provide({
- directory: two.path,
- fn: () => waitForPending(1),
- })
-
- 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"))
-
- 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" }),
- })
-
- await a.catch(() => {})
- await b.catch(() => {})
-})
-
-test("pending permission rejects on instance dispose", async () => {
- await using tmp = await tmpdir({ git: true })
-
- 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,
- )
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const pending = await waitForPending(1)
- expect(pending).toHaveLength(1)
- await Instance.dispose()
- },
- })
-
- expect(await result).toBeInstanceOf(Permission.RejectedError)
-})
-
-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({
+ 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()
+ }
+ }),
+ ),
+)
+
+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)
+
+ 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)
+
+ 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)
+
+ const onePending = yield* waitForPending(1).pipe(runOne)
+ const twoPending = yield* waitForPending(1).pipe(runTwo)
+
+ 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"))
+
+ yield* reply({ requestID: onePending[0].id, reply: "reject" }).pipe(runOne)
+ yield* reply({ requestID: twoPending[0].id, reply: "reject" }).pipe(runTwo)
+
+ yield* Fiber.await(a)
+ yield* Fiber.await(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)
+
+ expect(yield* waitForPending(1).pipe(run)).toHaveLength(1)
+ yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => 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)
+ }),
+)
+
+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)
+
+ expect(yield* waitForPending(1).pipe(run)).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)
+ }),
+)
+
+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)
+ }),
+ ),
+)
+
+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)
- },
- })
-})
-
-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({
+ )
+ expect(err).toBeInstanceOf(Permission.DeniedError)
+ }),
+ ),
+)
+
+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)
- },
- })
-})
-
-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 },
- )
-
- await waitForPending(1)
- ctl.abort()
- await ask.catch(() => {})
-
- try {
- expect(await Permission.list()).toHaveLength(0)
- } finally {
- await rejectAll()
- }
- },
- })
-})
+ expect(yield* list()).toHaveLength(0)
+ }),
+ ),
+)
+
+it.live("ask - abort should clear pending request", () =>
+ 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: [{ permission: "bash", pattern: "*", action: "ask" }],
+ }).pipe(run, Effect.forkScoped)
+
+ 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)
+ }),
+)