Move service state into InstanceState, flatten service facades (#18483)

This commit is contained in:
Kit Langton
2026-03-21 00:51:35 -04:00
committed by GitHub
parent 40aeaa120d
commit 38e0dc9ccd
84 changed files with 4536 additions and 3742 deletions

View File

@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo"
import { Account } from "../../src/account/effect"
import { Account } from "../../src/account"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect"

View File

@@ -1,16 +1,20 @@
import { test, expect } from "bun:test"
import { afterEach, test, expect } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent"
import { PermissionNext } from "../../src/permission"
import { Permission } from "../../src/permission"
// Helper to evaluate permission for a tool with wildcard pattern
function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
function evalPerm(agent: Agent.Info | undefined, permission: string): Permission.Action | undefined {
if (!agent) return undefined
return PermissionNext.evaluate(permission, "*", agent.permission).action
return Permission.evaluate(permission, "*", agent.permission).action
}
afterEach(async () => {
await Instance.disposeAll()
})
test("returns default native agents when no config", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -54,7 +58,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
// Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed
expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
expect(Permission.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
},
})
})
@@ -83,8 +87,8 @@ test("explore agent asks for external directories and allows Truncate.GLOB", asy
fn: async () => {
const explore = await Agent.get("explore")
expect(explore).toBeDefined()
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask")
expect(Permission.evaluate("external_directory", Truncate.GLOB, explore!.permission).action).toBe("allow")
},
})
})
@@ -216,7 +220,7 @@ test("agent permission config merges with defaults", async () => {
const build = await Agent.get("build")
expect(build).toBeDefined()
// Specific pattern is denied
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
expect(Permission.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
},
@@ -501,9 +505,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
})
@@ -525,9 +529,9 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
},
})
})
@@ -548,8 +552,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
expect(Permission.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
},
})
})
@@ -582,7 +586,7 @@ description: Permission skill.
const build = await Agent.get("build")
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
const target = path.join(skillDir, "reference", "notes.md")
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
expect(Permission.evaluate("external_directory", target, build!.permission).action).toBe("allow")
},
})
} finally {

View File

@@ -251,7 +251,7 @@ test("resolves env templates in account config with account token", async () =>
const originalToken = Account.token
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
Account.active = mock(() => ({
Account.active = mock(async () => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",

View File

@@ -0,0 +1,384 @@
import { afterEach, expect, test } from "bun:test"
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
import { InstanceState } from "../../src/effect/instance-state"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
async function access<A, E>(state: InstanceState<A, E>, dir: string) {
return Instance.provide({
directory: dir,
fn: () => Effect.runPromise(InstanceState.get(state)),
})
}
afterEach(async () => {
await Instance.disposeAll()
})
test("InstanceState caches values per directory", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ n: ++n })))
const a = yield* Effect.promise(() => access(state, tmp.path))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})
test("InstanceState isolates directories", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((dir) => Effect.sync(() => ({ dir, n: ++n })))
const a = yield* Effect.promise(() => access(state, one.path))
const b = yield* Effect.promise(() => access(state, two.path))
const c = yield* Effect.promise(() => access(state, one.path))
expect(a).toBe(c)
expect(a).not.toBe(b)
expect(n).toBe(2)
}),
),
)
})
test("InstanceState invalidates on reload", async () => {
await using tmp = await tmpdir()
const seen: string[] = []
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.acquireRelease(
Effect.sync(() => ({ n: ++n })),
(value) =>
Effect.sync(() => {
seen.push(String(value.n))
}),
),
)
const a = yield* Effect.promise(() => access(state, tmp.path))
yield* Effect.promise(() => Instance.reload({ directory: tmp.path }))
const b = yield* Effect.promise(() => access(state, tmp.path))
expect(a).not.toBe(b)
expect(seen).toEqual(["1"])
}),
),
)
})
test("InstanceState invalidates on disposeAll", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
const seen: string[] = []
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.acquireRelease(
Effect.sync(() => ({ dir: ctx.directory })),
(value) =>
Effect.sync(() => {
seen.push(value.dir)
}),
),
)
yield* Effect.promise(() => access(state, one.path))
yield* Effect.promise(() => access(state, two.path))
yield* Effect.promise(() => Instance.disposeAll())
expect(seen.sort()).toEqual([one.path, two.path].sort())
}),
),
)
})
test("InstanceState.get reads the current directory lazily", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateLazy") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
const get = InstanceState.get(state)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* get
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
const b = await Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
expect(b).toBe(two.path)
} finally {
await rt.dispose()
}
})
test("InstanceState preserves directory across async boundaries", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await using three = await tmpdir({ git: true })
interface Api {
readonly get: () => Effect.Effect<{ directory: string; worktree: string; project: string }>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InstanceStateAsync") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.sync(() => ({
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project.id,
})),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
yield* Effect.promise(() => Bun.sleep(1))
yield* Effect.sleep(Duration.millis(1))
for (let i = 0; i < 100; i++) {
yield* Effect.yieldNow
}
for (let i = 0; i < 100; i++) {
yield* Effect.promise(() => Promise.resolve())
}
yield* Effect.sleep(Duration.millis(2))
yield* Effect.promise(() => Bun.sleep(1))
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const [a, b, c] = await Promise.all([
Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
Instance.provide({
directory: three.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(a).toEqual({ directory: one.path, worktree: one.path, project: a.project })
expect(b).toEqual({ directory: two.path, worktree: two.path, project: b.project })
expect(c).toEqual({ directory: three.path, worktree: three.path, project: c.project })
expect(a.project).not.toBe(b.project)
expect(a.project).not.toBe(c.project)
expect(b.project).not.toBe(c.project)
} finally {
await rt.dispose()
}
})
test("InstanceState survives high-contention concurrent access", async () => {
const N = 20
const dirs = await Promise.all(Array.from({ length: N }, () => tmpdir()))
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/HighContention") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
return Test.of({
get: Effect.fn("Test.get")(function* () {
// Interleave many async hops to maximize chance of ALS corruption
for (let i = 0; i < 10; i++) {
yield* Effect.promise(() => Bun.sleep(Math.random() * 3))
yield* Effect.yieldNow
yield* Effect.promise(() => Promise.resolve())
}
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
const results = await Promise.all(
dirs.map((d) =>
Instance.provide({
directory: d.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
),
)
for (let i = 0; i < N; i++) {
expect(results[i]).toBe(dirs[i].path)
}
} finally {
await rt.dispose()
for (const d of dirs) await d[Symbol.asyncDispose]()
}
})
test("InstanceState correct after interleaved init and dispose", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
interface Api {
readonly get: () => Effect.Effect<string>
}
class Test extends ServiceMap.Service<Test, Api>()("@test/InterleavedDispose") {
static readonly layer = Layer.effect(
Test,
Effect.gen(function* () {
const state = yield* InstanceState.make((ctx) =>
Effect.promise(async () => {
await Bun.sleep(5) // slow init
return ctx.directory
}),
)
return Test.of({
get: Effect.fn("Test.get")(function* () {
return yield* InstanceState.get(state)
}),
})
}),
)
}
const rt = ManagedRuntime.make(Test.layer)
try {
// Init both directories
const a = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(a).toBe(one.path)
// Dispose one directory, access the other concurrently
const [, b] = await Promise.all([
Instance.reload({ directory: one.path }),
Instance.provide({
directory: two.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
}),
])
expect(b).toBe(two.path)
// Re-access disposed directory - should get fresh state
const c = await Instance.provide({
directory: one.path,
fn: () => rt.runPromise(Test.use((svc) => svc.get())),
})
expect(c).toBe(one.path)
} finally {
await rt.dispose()
}
})
test("InstanceState mutation in one directory does not leak to another", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() => Effect.sync(() => ({ count: 0 })))
// Mutate state in directory one
const s1 = yield* Effect.promise(() => access(state, one.path))
s1.count = 42
// Access directory two — should be independent
const s2 = yield* Effect.promise(() => access(state, two.path))
expect(s2.count).toBe(0)
// Confirm directory one still has the mutation
const s1again = yield* Effect.promise(() => access(state, one.path))
expect(s1again.count).toBe(42)
expect(s1again).toBe(s1) // same reference
}),
),
)
})
test("InstanceState dedupes concurrent lookups", async () => {
await using tmp = await tmpdir()
let n = 0
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const state = yield* InstanceState.make(() =>
Effect.promise(async () => {
n += 1
await Bun.sleep(10)
return { n }
}),
)
const [a, b] = yield* Effect.promise(() => Promise.all([access(state, tmp.path), access(state, tmp.path)]))
expect(a).toBe(b)
expect(n).toBe(1)
}),
),
)
})

View File

@@ -0,0 +1,46 @@
import { expect, test } from "bun:test"
import { Effect, Layer, ServiceMap } from "effect"
import { makeRunPromise } from "../../src/effect/run-service"
class Shared extends ServiceMap.Service<Shared, { readonly id: number }>()("@test/Shared") {}
test("makeRunPromise shares dependent layers through the shared memo map", async () => {
let n = 0
const shared = Layer.effect(
Shared,
Effect.sync(() => {
n += 1
return Shared.of({ id: n })
}),
)
class One extends ServiceMap.Service<One, { readonly get: () => Effect.Effect<number> }>()("@test/One") {}
const one = Layer.effect(
One,
Effect.gen(function* () {
const svc = yield* Shared
return One.of({
get: Effect.fn("One.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
class Two extends ServiceMap.Service<Two, { readonly get: () => Effect.Effect<number> }>()("@test/Two") {}
const two = Layer.effect(
Two,
Effect.gen(function* () {
const svc = yield* Shared
return Two.of({
get: Effect.fn("Two.get")(() => Effect.succeed(svc.id)),
})
}),
).pipe(Layer.provide(shared))
const runOne = makeRunPromise(One, one)
const runTwo = makeRunPromise(Two, two)
expect(await runOne((svc) => svc.get())).toBe(1)
expect(await runTwo((svc) => svc.get())).toBe(1)
expect(n).toBe(1)
})

View File

@@ -1,128 +0,0 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { runtime, runPromiseInstance } from "../../src/effect/runtime"
import { Auth } from "../../src/auth/effect"
import { Instances } from "../../src/effect/instances"
import { Instance } from "../../src/project/instance"
import { ProviderAuth } from "../../src/provider/auth"
import { Vcs } from "../../src/project/vcs"
import { Question } from "../../src/question"
import { tmpdir } from "../fixture/fixture"
/**
* Integration tests for the Effect runtime and LayerMap-based instance system.
*
* Each instance service layer has `.pipe(Layer.fresh)` at its definition site
* so it is always rebuilt per directory, while shared dependencies are provided
* outside the fresh boundary and remain memoizable.
*
* These tests verify the invariants using object identity (===) on the real
* production services — not mock services or return-value checks.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed))
describe("effect/runtime", () => {
afterEach(async () => {
await Instance.disposeAll()
})
test("global services are shared across directories", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
// Auth is a global service — it should be the exact same object
// regardless of which directory we're in.
const authOne = await Instance.provide({
directory: one.path,
fn: () => grabGlobal(Auth.Service),
})
const authTwo = await Instance.provide({
directory: two.path,
fn: () => grabGlobal(Auth.Service),
})
expect(authOne).toBe(authTwo)
})
test("instance services with global deps share the global (ProviderAuth → Auth)", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
// ProviderAuth depends on Auth via defaultLayer.
// The instance service itself should be different per directory,
// but the underlying Auth should be shared.
const paOne = await Instance.provide({
directory: one.path,
fn: () => grabInstance(ProviderAuth.Service),
})
const paTwo = await Instance.provide({
directory: two.path,
fn: () => grabInstance(ProviderAuth.Service),
})
// Different directories → different ProviderAuth instances.
expect(paOne).not.toBe(paTwo)
// But the global Auth is the same object in both.
const authOne = await Instance.provide({
directory: one.path,
fn: () => grabGlobal(Auth.Service),
})
const authTwo = await Instance.provide({
directory: two.path,
fn: () => grabGlobal(Auth.Service),
})
expect(authOne).toBe(authTwo)
})
test("instance services are shared within the same directory", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service))
expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service))
},
})
})
test("different directories get different service instances", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const vcsOne = await Instance.provide({
directory: one.path,
fn: () => grabInstance(Vcs.Service),
})
const vcsTwo = await Instance.provide({
directory: two.path,
fn: () => grabInstance(Vcs.Service),
})
expect(vcsOne).not.toBe(vcsTwo)
})
test("disposal rebuilds services with a new instance", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await grabInstance(Question.Service)
await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory)))
const after = await grabInstance(Question.Service)
expect(after).not.toBe(before)
},
})
})
})

View File

@@ -1,4 +1,4 @@
import { describe, test, expect } from "bun:test"
import { afterEach, describe, test, expect } from "bun:test"
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
@@ -7,6 +7,10 @@ import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
describe("file/index Filesystem patterns", () => {
describe("File.read() - text content", () => {
test("reads text file via Filesystem.readText()", async () => {
@@ -689,6 +693,18 @@ describe("file/index Filesystem patterns", () => {
})
})
test("search works before explicit init", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await File.search({ query: "main", type: "file" })
expect(result.some((f) => f.includes("main"))).toBe(true)
},
})
})
test("empty query returns dirs sorted with hidden last", async () => {
await using tmp = await setupSearchableRepo()
@@ -785,6 +801,23 @@ describe("file/index Filesystem patterns", () => {
},
})
})
test("search refreshes after init when files change", async () => {
await using tmp = await setupSearchableRepo()
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
expect(await File.search({ query: "fresh", type: "file" })).toEqual([])
await fs.writeFile(path.join(tmp.path, "fresh.ts"), "fresh", "utf-8")
const result = await File.search({ query: "fresh", type: "file" })
expect(result).toContain("fresh.ts")
},
})
})
})
describe("File.read() - diff/patch", () => {
@@ -849,4 +882,65 @@ describe("file/index Filesystem patterns", () => {
})
})
})
describe("InstanceState isolation", () => {
test("two directories get independent file caches", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
await fs.writeFile(path.join(one.path, "a.ts"), "one", "utf-8")
await fs.writeFile(path.join(two.path, "b.ts"), "two", "utf-8")
await Instance.provide({
directory: one.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "a.ts", type: "file" })
expect(results).toContain("a.ts")
const results2 = await File.search({ query: "b.ts", type: "file" })
expect(results2).not.toContain("b.ts")
},
})
await Instance.provide({
directory: two.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "b.ts", type: "file" })
expect(results).toContain("b.ts")
const results2 = await File.search({ query: "a.ts", type: "file" })
expect(results2).not.toContain("a.ts")
},
})
})
test("disposal gives fresh state on next access", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "before.ts"), "before", "utf-8")
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "before", type: "file" })
expect(results).toContain("before.ts")
},
})
await Instance.disposeAll()
await fs.writeFile(path.join(tmp.path, "after.ts"), "after", "utf-8")
await fs.rm(path.join(tmp.path, "before.ts"))
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.init()
const results = await File.search({ query: "after", type: "file" })
expect(results).toContain("after.ts")
const stale = await File.search({ query: "before", type: "file" })
expect(stale).not.toContain("before.ts")
},
})
})
})
})

View File

@@ -7,7 +7,9 @@ import { SessionID } from "../../src/session/schema"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
afterEach(() => Instance.disposeAll())
afterEach(async () => {
await Instance.disposeAll()
})
async function touch(file: string, time: number) {
const date = new Date(time)
@@ -84,6 +86,28 @@ describe("file/time", () => {
},
})
})
test("isolates reads by directory", async () => {
await using one = await tmpdir()
await using two = await tmpdir()
await using shared = await tmpdir()
const filepath = path.join(shared.path, "file.txt")
await fs.writeFile(filepath, "content", "utf-8")
await Instance.provide({
directory: one.path,
fn: async () => {
await FileTime.read(sessionID, filepath)
},
})
await Instance.provide({
directory: two.path,
fn: async () => {
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
},
})
})
})
describe("assert()", () => {

View File

@@ -25,7 +25,7 @@ function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
directory,
FileWatcher.layer,
async (rt) => {
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await Effect.runPromise(ready(directory))
await Effect.runPromise(body)
},
@@ -136,7 +136,9 @@ function ready(directory: string) {
// ---------------------------------------------------------------------------
describeWatcher("FileWatcher", () => {
afterEach(() => Instance.disposeAll())
afterEach(async () => {
await Instance.disposeAll()
})
test("publishes root create, update, and delete events", async () => {
await using tmp = await tmpdir({ git: true })

View File

@@ -2,11 +2,16 @@ import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance"
import { Bus } from "../../src/bus"
import { File } from "../../src/file"
import { Format } from "../../src/format"
import * as Formatter from "../../src/format/formatter"
import { Instance } from "../../src/project/instance"
describe("Format", () => {
afterEach(() => Instance.disposeAll())
afterEach(async () => {
await Instance.disposeAll()
})
test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir()
@@ -62,4 +67,106 @@ describe("Format", () => {
await rt.runPromise(Format.Service.use(() => Effect.void))
})
})
test("status() initializes formatter state per directory", async () => {
await using off = await tmpdir({
config: { formatter: false },
})
await using on = await tmpdir()
const a = await Instance.provide({
directory: off.path,
fn: () => Format.status(),
})
const b = await Instance.provide({
directory: on.path,
fn: () => Format.status(),
})
expect(a).toEqual([])
expect(b.length).toBeGreaterThan(0)
})
test("runs enabled checks for matching formatters in parallel", async () => {
await using tmp = await tmpdir()
const file = `${tmp.path}/test.parallel`
await Bun.write(file, "x")
const one = {
extensions: Formatter.gofmt.extensions,
enabled: Formatter.gofmt.enabled,
command: Formatter.gofmt.command,
}
const two = {
extensions: Formatter.mix.extensions,
enabled: Formatter.mix.enabled,
command: Formatter.mix.command,
}
let active = 0
let max = 0
Formatter.gofmt.extensions = [".parallel"]
Formatter.mix.extensions = [".parallel"]
Formatter.gofmt.command = ["sh", "-c", "true"]
Formatter.mix.command = ["sh", "-c", "true"]
Formatter.gofmt.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return true
}
Formatter.mix.enabled = async () => {
active++
max = Math.max(max, active)
await Bun.sleep(20)
active--
return true
}
try {
await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(Format.Service.use((s) => s.init()))
await Bus.publish(File.Event.Edited, { file })
})
} finally {
Formatter.gofmt.extensions = one.extensions
Formatter.gofmt.enabled = one.enabled
Formatter.gofmt.command = one.command
Formatter.mix.extensions = two.extensions
Formatter.mix.enabled = two.enabled
Formatter.mix.command = two.command
}
expect(max).toBe(2)
})
test("runs matching formatters sequentially for the same file", async () => {
await using tmp = await tmpdir({
config: {
formatter: {
first: {
command: ["sh", "-c", "sleep 0.05; v=$(cat \"$1\"); printf '%sA' \"$v\" > \"$1\"", "sh", "$FILE"],
extensions: [".seq"],
},
second: {
command: ["sh", "-c", "v=$(cat \"$1\"); printf '%sB' \"$v\" > \"$1\"", "sh", "$FILE"],
extensions: [".seq"],
},
},
},
})
const file = `${tmp.path}/test.seq`
await Bun.write(file, "x")
await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(Format.Service.use((s) => s.init()))
await Bus.publish(File.Event.Edited, { file })
})
expect(await Bun.file(file).text()).toBe("xAB")
})
})

View File

@@ -1,11 +1,15 @@
import { describe, test, expect } from "bun:test"
import { PermissionNext } from "../src/permission"
import { afterEach, describe, test, expect } from "bun:test"
import { Permission } from "../src/permission"
import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture"
describe("PermissionNext.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
afterEach(async () => {
await Instance.disposeAll()
})
describe("Permission.evaluate for permission.task", () => {
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,
@@ -13,42 +17,42 @@ describe("PermissionNext.evaluate for permission.task", () => {
}))
test("returns ask when no match (default)", () => {
expect(PermissionNext.evaluate("task", "code-reviewer", []).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", []).action).toBe("ask")
})
test("returns deny for explicit deny", () => {
const ruleset = createRuleset({ "code-reviewer": "deny" })
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
})
test("returns allow for explicit allow", () => {
const ruleset = createRuleset({ "code-reviewer": "allow" })
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("allow")
})
test("returns ask for explicit ask", () => {
const ruleset = createRuleset({ "code-reviewer": "ask" })
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
})
test("matches wildcard patterns with deny", () => {
const ruleset = createRuleset({ "orchestrator-*": "deny" })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
})
test("matches wildcard patterns with allow", () => {
const ruleset = createRuleset({ "orchestrator-*": "allow" })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
})
test("matches wildcard patterns with ask", () => {
const ruleset = createRuleset({ "orchestrator-*": "ask" })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("ask")
const globalRuleset = createRuleset({ "*": "ask" })
expect(PermissionNext.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", globalRuleset).action).toBe("ask")
})
test("later rules take precedence (last match wins)", () => {
@@ -56,22 +60,22 @@ describe("PermissionNext.evaluate for permission.task", () => {
"orchestrator-*": "deny",
"orchestrator-fast": "allow",
})
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
})
test("matches global wildcard", () => {
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny")
expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "ask" })).action).toBe("ask")
})
})
describe("PermissionNext.disabled for task tool", () => {
describe("Permission.disabled for task tool", () => {
// Note: The `disabled` function checks if a TOOL should be completely removed from the tool list.
// It only disables a tool when there's a rule with `pattern: "*"` and `action: "deny"`.
// It does NOT evaluate complex subagent patterns - those are handled at runtime by `evaluate`.
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): PermissionNext.Ruleset =>
const createRuleset = (rules: Record<string, "allow" | "deny" | "ask">): Permission.Ruleset =>
Object.entries(rules).map(([pattern, action]) => ({
permission: "task",
pattern,
@@ -85,7 +89,7 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "allow",
"*": "deny",
})
const disabled = PermissionNext.disabled(["task", "bash", "read"], ruleset)
const disabled = Permission.disabled(["task", "bash", "read"], ruleset)
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true)
})
@@ -95,14 +99,14 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "ask",
"*": "deny",
})
const disabled = PermissionNext.disabled(["task"], ruleset)
const disabled = Permission.disabled(["task"], ruleset)
// The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true)
})
test("task tool is disabled when global deny pattern exists", () => {
const ruleset = createRuleset({ "*": "deny" })
const disabled = PermissionNext.disabled(["task"], ruleset)
const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true)
})
@@ -113,13 +117,13 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "deny",
general: "deny",
})
const disabled = PermissionNext.disabled(["task"], ruleset)
const disabled = Permission.disabled(["task"], ruleset)
// The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(false)
})
test("task tool is enabled when no task rules exist (default ask)", () => {
const disabled = PermissionNext.disabled(["task"], [])
const disabled = Permission.disabled(["task"], [])
expect(disabled.has("task")).toBe(false)
})
@@ -129,7 +133,7 @@ describe("PermissionNext.disabled for task tool", () => {
"*": "deny",
"orchestrator-coder": "allow",
})
const disabled = PermissionNext.disabled(["task"], ruleset)
const disabled = Permission.disabled(["task"], ruleset)
// The disabled() function uses findLast and checks if the last matching rule
// has pattern: "*" and action: "deny". In this case, the last rule matching
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled
@@ -155,11 +159,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and orchestrator-fast should be allowed, code-reviewer denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
},
})
})
@@ -180,11 +184,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
const ruleset = Permission.fromConfig(config.permission ?? {})
// general and code-reviewer should be ask, orchestrator-* denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
},
})
})
@@ -205,11 +209,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
const ruleset = Permission.fromConfig(config.permission ?? {})
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask"
expect(PermissionNext.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "unknown-agent", ruleset).action).toBe("ask")
},
})
})
@@ -232,18 +236,18 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
const ruleset = Permission.fromConfig(config.permission ?? {})
// Verify task permissions
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Verify other tool permissions
expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask")
expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
// Verify disabled tools
const disabled = PermissionNext.disabled(["bash", "edit", "task"], ruleset)
const disabled = Permission.disabled(["bash", "edit", "task"], ruleset)
expect(disabled.has("bash")).toBe(false)
expect(disabled.has("edit")).toBe(false)
// task is NOT disabled because disabled() uses findLast, and the last rule
@@ -270,16 +274,16 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
const ruleset = Permission.fromConfig(config.permission ?? {})
// Last matching rule wins - "*" deny is last, so all agents are denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
// Since "*": "deny" is the last rule, disabled() finds it with findLast
// and sees pattern: "*" with action: "deny", so task is disabled
const disabled = PermissionNext.disabled(["task"], ruleset)
const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true)
},
})
@@ -301,17 +305,17 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {})
const ruleset = Permission.fromConfig(config.permission ?? {})
// Evaluate uses findLast - "general" allow comes after "*" deny
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
// Other agents still denied by the earlier "*" deny
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// disabled() uses findLast and checks if the last rule has pattern: "*" with action: "deny"
// In this case, the last rule is {pattern: "general", action: "allow"}, not pattern: "*"
// So the task tool is NOT disabled (even though most subagents are denied)
const disabled = PermissionNext.disabled(["task"], ruleset)
const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(false)
},
})

View File

@@ -1,11 +1,7 @@
import { afterEach, test, expect } from "bun:test"
import os from "os"
import { Effect } from "effect"
import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission"
import { PermissionNext as S } from "../../src/permission"
import { Permission } from "../../src/permission"
import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
@@ -16,8 +12,8 @@ afterEach(async () => {
})
async function rejectAll(message?: string) {
for (const req of await PermissionNext.list()) {
await PermissionNext.reply({
for (const req of await Permission.list()) {
await Permission.reply({
requestID: req.id,
reply: "reject",
message,
@@ -27,22 +23,22 @@ async function rejectAll(message?: string) {
async function waitForPending(count: number) {
for (let i = 0; i < 20; i++) {
const list = await PermissionNext.list()
const list = await Permission.list()
if (list.length === count) return list
await Bun.sleep(0)
}
return PermissionNext.list()
return Permission.list()
}
// fromConfig tests
test("fromConfig - string value becomes wildcard rule", () => {
const result = PermissionNext.fromConfig({ bash: "allow" })
const result = Permission.fromConfig({ bash: "allow" })
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("fromConfig - object value converts to rules array", () => {
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } })
const result = Permission.fromConfig({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
@@ -50,7 +46,7 @@ test("fromConfig - object value converts to rules array", () => {
})
test("fromConfig - mixed string and object values", () => {
const result = PermissionNext.fromConfig({
const result = Permission.fromConfig({
bash: { "*": "allow", rm: "deny" },
edit: "allow",
webfetch: "ask",
@@ -64,51 +60,51 @@ test("fromConfig - mixed string and object values", () => {
})
test("fromConfig - empty object", () => {
const result = PermissionNext.fromConfig({})
const result = Permission.fromConfig({})
expect(result).toEqual([])
})
test("fromConfig - expands tilde to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
})
test("fromConfig - expands $HOME to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
})
test("fromConfig - expands $HOME without trailing slash", () => {
const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } })
const result = Permission.fromConfig({ external_directory: { $HOME: "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
})
test("fromConfig - does not expand tilde in middle of path", () => {
const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } })
const result = Permission.fromConfig({ external_directory: { "/some/~/path": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
})
test("fromConfig - expands exact tilde to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } })
const result = Permission.fromConfig({ external_directory: { "~": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
})
test("evaluate - matches expanded tilde pattern", () => {
const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
const ruleset = Permission.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow")
})
test("evaluate - matches expanded $HOME pattern", () => {
const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
const ruleset = Permission.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = Permission.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow")
})
// merge tests
test("merge - simple concatenation", () => {
const result = PermissionNext.merge(
const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "*", action: "deny" }],
)
@@ -119,7 +115,7 @@ test("merge - simple concatenation", () => {
})
test("merge - adds new permission", () => {
const result = PermissionNext.merge(
const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "edit", pattern: "*", action: "deny" }],
)
@@ -130,7 +126,7 @@ test("merge - adds new permission", () => {
})
test("merge - concatenates rules for same permission", () => {
const result = PermissionNext.merge(
const result = Permission.merge(
[{ permission: "bash", pattern: "foo", action: "ask" }],
[{ permission: "bash", pattern: "*", action: "deny" }],
)
@@ -141,7 +137,7 @@ test("merge - concatenates rules for same permission", () => {
})
test("merge - multiple rulesets", () => {
const result = PermissionNext.merge(
const result = Permission.merge(
[{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "rm", action: "ask" }],
[{ permission: "edit", pattern: "*", action: "allow" }],
@@ -154,12 +150,12 @@ test("merge - multiple rulesets", () => {
})
test("merge - empty ruleset does nothing", () => {
const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
const result = Permission.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
})
test("merge - preserves rule order", () => {
const result = PermissionNext.merge(
const result = Permission.merge(
[
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret/*", action: "deny" },
@@ -175,40 +171,40 @@ test("merge - preserves rule order", () => {
test("merge - config permission overrides default ask", () => {
// Simulates: defaults have "*": "ask", config sets bash: "allow"
const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = PermissionNext.merge(defaults, config)
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(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow")
expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow")
// Other permissions should still be ask (from defaults)
expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask")
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: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = PermissionNext.merge(defaults, config)
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(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask")
expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask")
})
// evaluate tests
test("evaluate - exact pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
const result = Permission.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
expect(result.action).toBe("allow")
})
test("evaluate - last matching rule wins", () => {
const result = PermissionNext.evaluate("bash", "rm", [
const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" },
])
@@ -216,7 +212,7 @@ test("evaluate - last matching rule wins", () => {
})
test("evaluate - last matching rule wins (wildcard after specific)", () => {
const result = PermissionNext.evaluate("bash", "rm", [
const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -224,14 +220,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => {
})
test("evaluate - glob pattern match", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "src/*", action: "allow" },
])
const result = Permission.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
expect(result.action).toBe("allow")
})
test("evaluate - last matching glob wins", () => {
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/*", action: "deny" },
{ permission: "edit", pattern: "src/components/*", action: "allow" },
])
@@ -240,7 +234,7 @@ test("evaluate - last matching glob wins", () => {
test("evaluate - order matters for specificity", () => {
// If more specific rule comes first, later wildcard overrides it
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [
const result = Permission.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/components/*", action: "allow" },
{ permission: "edit", pattern: "src/*", action: "deny" },
])
@@ -248,31 +242,29 @@ test("evaluate - order matters for specificity", () => {
})
test("evaluate - unknown permission returns ask", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", [
const result = Permission.evaluate("unknown_tool", "anything", [
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("ask")
})
test("evaluate - empty ruleset returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", [])
const result = Permission.evaluate("bash", "rm", [])
expect(result.action).toBe("ask")
})
test("evaluate - no matching pattern returns ask", () => {
const result = PermissionNext.evaluate("edit", "etc/passwd", [
{ permission: "edit", pattern: "src/*", action: "allow" },
])
const result = Permission.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
expect(result.action).toBe("ask")
})
test("evaluate - empty rules array returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", [])
const result = Permission.evaluate("bash", "rm", [])
expect(result.action).toBe("ask")
})
test("evaluate - multiple matching patterns, last wins", () => {
const result = PermissionNext.evaluate("edit", "src/secret.ts", [
const result = Permission.evaluate("edit", "src/secret.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret.ts", action: "deny" },
@@ -281,7 +273,7 @@ test("evaluate - multiple matching patterns, last wins", () => {
})
test("evaluate - non-matching patterns are skipped", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
const result = Permission.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "test/*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
@@ -290,7 +282,7 @@ test("evaluate - non-matching patterns are skipped", () => {
})
test("evaluate - exact match at end wins over earlier wildcard", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", [
const result = Permission.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
])
@@ -298,7 +290,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
})
test("evaluate - wildcard at end overrides earlier exact match", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", [
const result = Permission.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "/bin/rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -308,24 +300,24 @@ test("evaluate - wildcard at end overrides earlier exact match", () => {
// wildcard permission tests
test("evaluate - wildcard permission matches any permission", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - wildcard permission with specific pattern", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
const result = Permission.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny")
})
test("evaluate - glob permission pattern", () => {
const result = PermissionNext.evaluate("mcp_server_tool", "anything", [
const result = Permission.evaluate("mcp_server_tool", "anything", [
{ permission: "mcp_*", pattern: "*", action: "allow" },
])
expect(result.action).toBe("allow")
})
test("evaluate - specific permission and wildcard permission combined", () => {
const result = PermissionNext.evaluate("bash", "rm", [
const result = Permission.evaluate("bash", "rm", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -333,7 +325,7 @@ test("evaluate - specific permission and wildcard permission combined", () => {
})
test("evaluate - wildcard permission does not match when specific exists", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [
const result = Permission.evaluate("edit", "src/foo.ts", [
{ permission: "*", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" },
])
@@ -341,7 +333,7 @@ test("evaluate - wildcard permission does not match when specific exists", () =>
})
test("evaluate - multiple matching permission patterns combine rules", () => {
const result = PermissionNext.evaluate("mcp_dangerous", "anything", [
const result = Permission.evaluate("mcp_dangerous", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "mcp_*", pattern: "*", action: "allow" },
{ permission: "mcp_dangerous", pattern: "*", action: "deny" },
@@ -350,7 +342,7 @@ test("evaluate - multiple matching permission patterns combine rules", () => {
})
test("evaluate - wildcard permission fallback for unknown tool", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", [
const result = Permission.evaluate("unknown_tool", "anything", [
{ permission: "*", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "*", action: "allow" },
])
@@ -359,7 +351,7 @@ 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 = PermissionNext.evaluate("bash", "rm", [
const result = Permission.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" },
])
@@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order
})
test("evaluate - merges multiple rulesets", () => {
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
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 = PermissionNext.evaluate("bash", "rm", config, approved)
const result = Permission.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny")
})
// disabled tests
test("disabled - returns empty set when all tools allowed", () => {
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
expect(result.size).toBe(0)
})
test("disabled - disables tool when denied", () => {
const result = PermissionNext.disabled(
const result = Permission.disabled(
["bash", "edit", "read"],
[
{ permission: "*", pattern: "*", action: "allow" },
@@ -396,7 +388,7 @@ test("disabled - disables tool when denied", () => {
})
test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
const result = PermissionNext.disabled(
const result = Permission.disabled(
["edit", "write", "apply_patch", "multiedit", "bash"],
[
{ permission: "*", pattern: "*", action: "allow" },
@@ -411,7 +403,7 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", ()
})
test("disabled - does not disable when partially denied", () => {
const result = PermissionNext.disabled(
const result = Permission.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "allow" },
@@ -422,14 +414,14 @@ test("disabled - does not disable when partially denied", () => {
})
test("disabled - does not disable when action is ask", () => {
const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
const result = Permission.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
expect(result.size).toBe(0)
})
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 = PermissionNext.disabled(
const result = Permission.disabled(
["bash"],
[
{ permission: "bash", pattern: "*", action: "deny" },
@@ -440,7 +432,7 @@ test("disabled - does not disable when specific allow after wildcard deny", () =
})
test("disabled - does not disable when wildcard allow after deny", () => {
const result = PermissionNext.disabled(
const result = Permission.disabled(
["bash"],
[
{ permission: "bash", pattern: "rm *", action: "deny" },
@@ -451,7 +443,7 @@ test("disabled - does not disable when wildcard allow after deny", () => {
})
test("disabled - disables multiple tools", () => {
const result = PermissionNext.disabled(
const result = Permission.disabled(
["bash", "edit", "webfetch"],
[
{ permission: "bash", pattern: "*", action: "deny" },
@@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => {
})
test("disabled - wildcard permission denies all tools", () => {
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
const result = Permission.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true)
})
test("disabled - specific allow overrides wildcard deny", () => {
const result = PermissionNext.disabled(
const result = Permission.disabled(
["bash", "edit", "read"],
[
{ permission: "*", pattern: "*", action: "deny" },
@@ -491,7 +483,7 @@ test("ask - resolves immediately when action is allow", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await PermissionNext.ask({
const result = await Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -510,7 +502,7 @@ test("ask - throws RejectedError when action is deny", async () => {
directory: tmp.path,
fn: async () => {
await expect(
PermissionNext.ask({
Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["rm -rf /"],
@@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => {
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}),
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
).rejects.toBeInstanceOf(Permission.DeniedError)
},
})
})
@@ -528,7 +520,7 @@ test("ask - returns pending promise when action is ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const promise = PermissionNext.ask({
const promise = Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -550,7 +542,7 @@ test("ask - adds request to pending list", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
const ask = Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -563,7 +555,7 @@ test("ask - adds request to pending list", async () => {
ruleset: [],
})
const list = await PermissionNext.list()
const list = await Permission.list()
expect(list).toHaveLength(1)
expect(list[0]).toMatchObject({
sessionID: SessionID.make("session_test"),
@@ -588,12 +580,12 @@ test("ask - publishes asked event", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
let seen: PermissionNext.Request | undefined
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => {
let seen: Permission.Request | undefined
const unsub = Bus.subscribe(Permission.Event.Asked, (event) => {
seen = event.properties
})
const ask = PermissionNext.ask({
const ask = Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["ls"],
@@ -606,7 +598,7 @@ test("ask - publishes asked event", async () => {
ruleset: [],
})
expect(await PermissionNext.list()).toHaveLength(1)
expect(await Permission.list()).toHaveLength(1)
expect(seen).toBeDefined()
expect(seen).toMatchObject({
sessionID: SessionID.make("session_test"),
@@ -628,7 +620,7 @@ test("reply - once resolves the pending ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
const askPromise = Permission.ask({
id: PermissionID.make("per_test1"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -640,7 +632,7 @@ test("reply - once resolves the pending ask", async () => {
await waitForPending(1)
await PermissionNext.reply({
await Permission.reply({
requestID: PermissionID.make("per_test1"),
reply: "once",
})
@@ -655,7 +647,7 @@ test("reply - reject throws RejectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
const askPromise = Permission.ask({
id: PermissionID.make("per_test2"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -667,12 +659,12 @@ test("reply - reject throws RejectedError", async () => {
await waitForPending(1)
await PermissionNext.reply({
await Permission.reply({
requestID: PermissionID.make("per_test2"),
reply: "reject",
})
await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError)
await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError)
},
})
})
@@ -682,7 +674,7 @@ test("reply - reject with message throws CorrectedError", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
const ask = Permission.ask({
id: PermissionID.make("per_test2b"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -694,14 +686,14 @@ test("reply - reject with message throws CorrectedError", async () => {
await waitForPending(1)
await PermissionNext.reply({
await Permission.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).toBeInstanceOf(Permission.CorrectedError)
expect(err.message).toContain("Use a safer command")
},
})
@@ -712,7 +704,7 @@ test("reply - always persists approval and resolves", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise = PermissionNext.ask({
const askPromise = Permission.ask({
id: PermissionID.make("per_test3"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -724,7 +716,7 @@ test("reply - always persists approval and resolves", async () => {
await waitForPending(1)
await PermissionNext.reply({
await Permission.reply({
requestID: PermissionID.make("per_test3"),
reply: "always",
})
@@ -737,7 +729,7 @@ test("reply - always persists approval and resolves", async () => {
directory: tmp.path,
fn: async () => {
// Stored approval should allow without asking
const result = await PermissionNext.ask({
const result = await Permission.ask({
sessionID: SessionID.make("session_test2"),
permission: "bash",
patterns: ["ls"],
@@ -755,7 +747,7 @@ test("reply - reject cancels all pending for same session", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const askPromise1 = PermissionNext.ask({
const askPromise1 = Permission.ask({
id: PermissionID.make("per_test4a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -765,7 +757,7 @@ test("reply - reject cancels all pending for same session", async () => {
ruleset: [],
})
const askPromise2 = PermissionNext.ask({
const askPromise2 = Permission.ask({
id: PermissionID.make("per_test4b"),
sessionID: SessionID.make("session_same"),
permission: "edit",
@@ -782,14 +774,14 @@ test("reply - reject cancels all pending for same session", async () => {
const result2 = askPromise2.catch((e) => e)
// Reject the first one
await PermissionNext.reply({
await Permission.reply({
requestID: PermissionID.make("per_test4a"),
reply: "reject",
})
// Both should be rejected
expect(await result1).toBeInstanceOf(PermissionNext.RejectedError)
expect(await result2).toBeInstanceOf(PermissionNext.RejectedError)
expect(await result1).toBeInstanceOf(Permission.RejectedError)
expect(await result2).toBeInstanceOf(Permission.RejectedError)
},
})
})
@@ -799,7 +791,7 @@ test("reply - always resolves matching pending requests in same session", async
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = PermissionNext.ask({
const a = Permission.ask({
id: PermissionID.make("per_test5a"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -809,7 +801,7 @@ test("reply - always resolves matching pending requests in same session", async
ruleset: [],
})
const b = PermissionNext.ask({
const b = Permission.ask({
id: PermissionID.make("per_test5b"),
sessionID: SessionID.make("session_same"),
permission: "bash",
@@ -821,14 +813,14 @@ test("reply - always resolves matching pending requests in same session", async
await waitForPending(2)
await PermissionNext.reply({
await Permission.reply({
requestID: PermissionID.make("per_test5a"),
reply: "always",
})
await expect(a).resolves.toBeUndefined()
await expect(b).resolves.toBeUndefined()
expect(await PermissionNext.list()).toHaveLength(0)
expect(await Permission.list()).toHaveLength(0)
},
})
})
@@ -838,7 +830,7 @@ test("reply - always keeps other session pending", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const a = PermissionNext.ask({
const a = Permission.ask({
id: PermissionID.make("per_test6a"),
sessionID: SessionID.make("session_a"),
permission: "bash",
@@ -848,7 +840,7 @@ test("reply - always keeps other session pending", async () => {
ruleset: [],
})
const b = PermissionNext.ask({
const b = Permission.ask({
id: PermissionID.make("per_test6b"),
sessionID: SessionID.make("session_b"),
permission: "bash",
@@ -860,13 +852,13 @@ test("reply - always keeps other session pending", async () => {
await waitForPending(2)
await PermissionNext.reply({
await Permission.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")])
expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")])
await rejectAll()
await b.catch(() => {})
@@ -879,7 +871,7 @@ test("reply - publishes replied event", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ask = PermissionNext.ask({
const ask = Permission.ask({
id: PermissionID.make("per_test7"),
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -895,14 +887,14 @@ test("reply - publishes replied event", async () => {
| {
sessionID: SessionID
requestID: PermissionID
reply: PermissionNext.Reply
reply: Permission.Reply
}
| undefined
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => {
const unsub = Bus.subscribe(Permission.Event.Replied, (event) => {
seen = event.properties
})
await PermissionNext.reply({
await Permission.reply({
requestID: PermissionID.make("per_test7"),
reply: "once",
})
@@ -918,16 +910,141 @@ test("reply - publishes replied event", async () => {
})
})
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 PermissionNext.reply({
await Permission.reply({
requestID: PermissionID.make("per_unknown"),
reply: "once",
})
expect(await PermissionNext.list()).toHaveLength(0)
expect(await Permission.list()).toHaveLength(0)
},
})
})
@@ -938,7 +1055,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
directory: tmp.path,
fn: async () => {
await expect(
PermissionNext.ask({
Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
@@ -949,7 +1066,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
{ permission: "bash", pattern: "rm *", action: "deny" },
],
}),
).rejects.toBeInstanceOf(PermissionNext.DeniedError)
).rejects.toBeInstanceOf(Permission.DeniedError)
},
})
})
@@ -959,7 +1076,7 @@ test("ask - allows all patterns when all match allow rules", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const result = await PermissionNext.ask({
const result = await Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "ls -la", "pwd"],
@@ -977,7 +1094,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const err = await PermissionNext.ask({
const err = await Permission.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["echo hello", "rm -rf /"],
@@ -992,8 +1109,8 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
(err) => err,
)
expect(err).toBeInstanceOf(PermissionNext.DeniedError)
expect(await PermissionNext.list()).toHaveLength(0)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(await Permission.list()).toHaveLength(0)
},
})
})
@@ -1004,8 +1121,8 @@ test("ask - abort should clear pending request", async () => {
directory: tmp.path,
fn: async () => {
const ctl = new AbortController()
const ask = runtime.runPromise(
S.Service.use((svc) =>
const ask = Permission.runPromise(
(svc) =>
svc.ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
@@ -1014,7 +1131,6 @@ test("ask - abort should clear pending request", async () => {
always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
}),
).pipe(Effect.provide(Instances.get(Instance.directory))),
{ signal: ctl.signal },
)
@@ -1023,7 +1139,7 @@ test("ask - abort should clear pending request", async () => {
await ask.catch(() => {})
try {
expect(await PermissionNext.list()).toHaveLength(0)
expect(await Permission.list()).toHaveLength(0)
} finally {
await rejectAll()
}

View File

@@ -31,15 +31,26 @@ describe("plugin.auth-override", () => {
},
})
await Instance.provide({
await using plain = await tmpdir()
const methods = await Instance.provide({
directory: tmp.path,
fn: async () => {
const methods = await ProviderAuth.methods()
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
return ProviderAuth.methods()
},
})
const plainMethods = await Instance.provide({
directory: plain.path,
fn: async () => {
return ProviderAuth.methods()
},
})
const copilot = methods[ProviderID.make("github-copilot")]
expect(copilot).toBeDefined()
expect(copilot.length).toBe(1)
expect(copilot[0].label).toBe("Test Override Auth")
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000) // Increased timeout for plugin installation
})

View File

@@ -25,8 +25,8 @@ function withVcs(
directory,
Layer.merge(FileWatcher.layer, Vcs.layer),
async (rt) => {
await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await rt.runPromise(Vcs.Service.use(() => Effect.void))
await rt.runPromise(FileWatcher.Service.use((s) => s.init()))
await rt.runPromise(Vcs.Service.use((s) => s.init()))
await Bun.sleep(500)
await body(rt)
},
@@ -67,7 +67,9 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
// ---------------------------------------------------------------------------
describeVcs("Vcs", () => {
afterEach(() => Instance.disposeAll())
afterEach(async () => {
await Instance.disposeAll()
})
test("branch() returns current branch name", async () => {
await using tmp = await tmpdir({ git: true })

View File

@@ -320,3 +320,134 @@ test("list - returns empty when no pending", async () => {
},
})
})
test("questions stay isolated by directory", async () => {
await using one = await tmpdir({ git: true })
await using two = await tmpdir({ git: true })
const p1 = Instance.provide({
directory: one.path,
fn: () =>
Question.ask({
sessionID: SessionID.make("ses_one"),
questions: [
{
question: "Question 1?",
header: "Q1",
options: [{ label: "A", description: "A" }],
},
],
}),
})
const p2 = Instance.provide({
directory: two.path,
fn: () =>
Question.ask({
sessionID: SessionID.make("ses_two"),
questions: [
{
question: "Question 2?",
header: "Q2",
options: [{ label: "B", description: "B" }],
},
],
}),
})
const onePending = await Instance.provide({
directory: one.path,
fn: () => Question.list(),
})
const twoPending = await Instance.provide({
directory: two.path,
fn: () => Question.list(),
})
expect(onePending.length).toBe(1)
expect(twoPending.length).toBe(1)
expect(onePending[0].sessionID).toBe(SessionID.make("ses_one"))
expect(twoPending[0].sessionID).toBe(SessionID.make("ses_two"))
await Instance.provide({
directory: one.path,
fn: () => Question.reject(onePending[0].id),
})
await Instance.provide({
directory: two.path,
fn: () => Question.reject(twoPending[0].id),
})
await p1.catch(() => {})
await p2.catch(() => {})
})
test("pending question rejects on instance dispose", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
sessionID: SessionID.make("ses_dispose"),
questions: [
{
question: "Dispose me?",
header: "Dispose",
options: [{ label: "Yes", description: "Yes" }],
},
],
})
},
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
await Instance.dispose()
},
})
expect(await result).toBeInstanceOf(Question.RejectedError)
})
test("pending question rejects on instance reload", async () => {
await using tmp = await tmpdir({ git: true })
const ask = Instance.provide({
directory: tmp.path,
fn: () => {
return Question.ask({
sessionID: SessionID.make("ses_reload"),
questions: [
{
question: "Reload me?",
header: "Reload",
options: [{ label: "Yes", description: "Yes" }],
},
],
})
},
})
const result = ask.then(
() => "resolved" as const,
(err) => err,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const pending = await Question.list()
expect(pending).toHaveLength(1)
await Instance.reload({ directory: tmp.path })
},
})
expect(await result).toBeInstanceOf(Question.RejectedError)
})

View File

@@ -7,7 +7,7 @@ test("ShareNext.request uses legacy share API without active org account", async
const originalActive = Account.active
const originalConfigGet = Config.get
Account.active = mock(() => undefined)
Account.active = mock(async () => undefined)
Config.get = mock(async () => ({ enterprise: { url: "https://legacy-share.example.com" } }))
try {
@@ -29,7 +29,7 @@ test("ShareNext.request uses org share API with auth headers when account is act
const originalActive = Account.active
const originalToken = Account.token
Account.active = mock(() => ({
Account.active = mock(async () => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
@@ -59,7 +59,7 @@ test("ShareNext.request fails when org account has no token", async () => {
const originalActive = Account.active
const originalToken = Account.token
Account.active = mock(() => ({
Account.active = mock(async () => ({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",

View File

@@ -1,10 +1,14 @@
import { test, expect } from "bun:test"
import { afterEach, test, expect } from "bun:test"
import { Skill } from "../../src/skill"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import path from "path"
import fs from "fs/promises"
afterEach(async () => {
await Instance.disposeAll()
})
async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
await fs.mkdir(skillDir, { recursive: true })

View File

@@ -1,4 +1,4 @@
import { test, expect } from "bun:test"
import { afterEach, test, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
@@ -12,6 +12,10 @@ import { tmpdir } from "../fixture/fixture"
// This helper does the same for expected values so assertions match cross-platform.
const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/")
afterEach(async () => {
await Instance.disposeAll()
})
async function bootstrap() {
return tmpdir({
git: true,

View File

@@ -5,7 +5,7 @@ import { BashTool } from "../../src/tool/bash"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import type { PermissionNext } from "../../src/permission"
import type { Permission } from "../../src/permission"
import { Truncate } from "../../src/tool/truncate"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -49,10 +49,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -76,10 +76,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -104,10 +104,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -130,10 +130,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -163,10 +163,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -193,10 +193,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -223,10 +223,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -250,10 +250,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -276,10 +276,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -297,10 +297,10 @@ describe("tool.bash permissions", () => {
directory: tmp.path,
fn: async () => {
const bash = await BashTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}

View File

@@ -1,4 +1,4 @@
import { describe, test, expect } from "bun:test"
import { afterEach, describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { EditTool } from "../../src/tool/edit"
@@ -18,6 +18,10 @@ const ctx = {
ask: async () => {},
}
afterEach(async () => {
await Instance.disposeAll()
})
async function touch(file: string, time: number) {
const date = new Date(time)
await fs.utimes(file, date, date)

View File

@@ -3,7 +3,7 @@ import path from "path"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { assertExternalDirectory } from "../../src/tool/external-directory"
import type { PermissionNext } from "../../src/permission"
import type { Permission } from "../../src/permission"
import { SessionID, MessageID } from "../../src/session/schema"
const baseCtx: Omit<Tool.Context, "ask"> = {
@@ -18,7 +18,7 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
describe("tool.assertExternalDirectory", () => {
test("no-ops for empty target", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
@@ -37,7 +37,7 @@ describe("tool.assertExternalDirectory", () => {
})
test("no-ops for paths inside Instance.directory", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
@@ -56,7 +56,7 @@ describe("tool.assertExternalDirectory", () => {
})
test("asks with a single canonical glob", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
@@ -82,7 +82,7 @@ describe("tool.assertExternalDirectory", () => {
})
test("uses target directory when kind=directory", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
@@ -108,7 +108,7 @@ describe("tool.assertExternalDirectory", () => {
})
test("skips prompting when bypass=true", async () => {
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {

View File

@@ -1,15 +1,19 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission"
import { Permission } from "../../src/permission"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
afterEach(async () => {
await Instance.disposeAll()
})
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
@@ -65,10 +69,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -91,10 +95,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -112,10 +116,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -138,10 +142,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const testCtx = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req)
},
}
@@ -176,14 +180,14 @@ describe("tool.read env file permissions", () => {
let askedForEnv = false
const ctxWithPermissions = {
...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
for (const pattern of req.patterns) {
const rule = PermissionNext.evaluate(req.permission, pattern, agent.permission)
const rule = Permission.evaluate(req.permission, pattern, agent.permission)
if (rule.action === "ask" && req.permission === "read") {
askedForEnv = true
}
if (rule.action === "deny") {
throw new PermissionNext.DeniedError({ ruleset: agent.permission })
throw new Permission.DeniedError({ ruleset: agent.permission })
}
}
},

View File

@@ -1,10 +1,14 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ToolRegistry } from "../../src/tool/registry"
afterEach(async () => {
await Instance.disposeAll()
})
describe("tool.registry", () => {
test("loads tools from .opencode/tool (singular)", async () => {
await using tmp = await tmpdir({

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import type { PermissionNext } from "../../src/permission"
import type { Permission } from "../../src/permission"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
@@ -18,6 +18,10 @@ const baseCtx: Omit<Tool.Context, "ask"> = {
metadata: () => {},
}
afterEach(async () => {
await Instance.disposeAll()
})
describe("tool.skill", () => {
test("description lists skill location URL", async () => {
await using tmp = await tmpdir({
@@ -133,7 +137,7 @@ Use this skill.
directory: tmp.path,
fn: async () => {
const tool = await SkillTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {

View File

@@ -1,9 +1,13 @@
import { describe, expect, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { TaskTool } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
await Instance.disposeAll()
})
describe("tool.task", () => {
test("description sorts subagents by name and is stable across calls", async () => {
await using tmp = await tmpdir({

View File

@@ -1,8 +1,7 @@
import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate"
import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect"
import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate"
import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem"
@@ -129,7 +128,7 @@ describe("Truncate", () => {
})
test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
cwd: ROOT,
})

View File

@@ -1,4 +1,4 @@
import { describe, test, expect } from "bun:test"
import { afterEach, describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { WriteTool } from "../../src/tool/write"
@@ -17,6 +17,10 @@ const ctx = {
ask: async () => {},
}
afterEach(async () => {
await Instance.disposeAll()
})
describe("tool.write", () => {
describe("new file creation", () => {
test("writes content to new file", async () => {