fix: lazy runtime imports in facades to break bundle cycles

All service facades now use @/effect/run (lazy runtime import) instead
of directly importing @/effect/runtime. This breaks the circular
dependency chain that caused "undefined is not an object" crashes in
bun's bundled binary.

- Add src/effect/run.ts with run() and runInstance() lazy wrappers
- Strip all facades to runtime-only functions (no schema re-exports)
- Consumers that need schemas import from service modules directly
- Update specs/effect-migration.md with facade rules and full list
fix/insufferable-cycle-cyclone
Kit Langton 2026-03-20 18:57:23 -04:00
parent 552269d8f9
commit 1dae9c2369
37 changed files with 313 additions and 390 deletions

View File

@ -46,35 +46,64 @@ Rules:
- Export `defaultLayer` only when wiring dependencies is useful - Export `defaultLayer` only when wiring dependencies is useful
- Use the direct namespace form once the module is fully migrated - Use the direct namespace form once the module is fully migrated
## Temporary mixed-mode pattern ## Service / Facade split
Prefer a single namespace whenever possible. Migrated services are split into two files:
Use a `*Effect` namespace only when there is a real mixed-mode split, usually because a legacy boundary facade still exists or because merging everything immediately would create awkward cycles. - **Service module** (`service.ts`, `*-service.ts`, or `*-effect.ts`) — contains `Interface`, `Service`, `layer`, `defaultLayer`, schemas, types, errors, and pure helpers. Must **never** import `@/effect/runtime`.
- **Facade** (`index.ts`) — thin async wrapper that calls `runInstance()` or `run()` from `@/effect/run`. Contains **only** runtime-backed convenience functions. No re-exports of schemas, types, Service, layer, or anything else.
### Facade rules (critical for bundle safety)
1. **No eager import of `@/effect/runtime`** — use `run()` / `runInstance()` from `@/effect/run` instead, which lazy-imports the runtime.
2. **No eager import of the service module** if the service is in the circular dependency SCC (auth, account, skill, truncate). Use the lazy `svc()` pattern:
```ts
const svc = () => import("./service").then((m) => m.Foo.Service)
```
3. **No value re-exports** — consumers that need schemas, types, `Service`, or `layer` import from the service module directly.
4. **Only async wrapper functions** — each function awaits `svc()` and passes an Effect to `run()` / `runInstance()`.
### Why
Bun's bundler flattens all modules into a single file. When a circular dependency exists (`runtime → instances → services → config → auth → runtime`), the bundler picks an arbitrary evaluation order. If a facade eagerly imports `@/effect/runtime` or re-exports values from a service in the SCC, those values may be `undefined` when accessed at module load time — causing `undefined is not an object` crashes.
The lazy `svc()` + `run()` pattern defers all access to call time, when all modules have finished initializing.
### Example facade
```ts ```ts
export namespace FooEffect { // src/question/index.ts (facade)
export interface Interface { import { runInstance } from "@/effect/run"
readonly get: (id: FooID) => Effect.Effect<Foo, FooError> import type { Question as S } from "./service"
const svc = () => import("./service").then((m) => m.Question.Service)
export namespace Question {
export async function ask(input: { ... }): Promise<S.Answer[]> {
return runInstance((await svc()).use((s) => s.ask(input)))
} }
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Foo") {} export async function list() {
return runInstance((await svc()).use((s) => s.list()))
export const layer = Layer.effect(...)
}
```
Then keep the old boundary thin:
```ts
export namespace Foo {
export function get(id: FooID) {
return runtime.runPromise(FooEffect.Service.use((svc) => svc.get(id)))
} }
} }
``` ```
Remove the `Effect` suffix when the boundary split is gone. ### Current facades
| Facade | Service module | Scope |
|---|---|---|
| `src/question/index.ts` | `src/question/service.ts` | instance |
| `src/permission/index.ts` | `src/permission/service.ts` | instance |
| `src/format/index.ts` | `src/format/service.ts` | instance |
| `src/file/index.ts` | `src/file/service.ts` | instance |
| `src/file/time.ts` | `src/file/time-service.ts` | instance |
| `src/provider/auth.ts` | `src/provider/auth-service.ts` | instance |
| `src/skill/index.ts` | `src/skill/service.ts` | instance |
| `src/snapshot/index.ts` | `src/snapshot/service.ts` | instance |
| `src/auth/index.ts` | `src/auth/effect.ts` | global |
| `src/account/index.ts` | `src/account/effect.ts` | global |
| `src/tool/truncate.ts` | `src/tool/truncate-effect.ts` | global |
## Scheduled Tasks ## Scheduled Tasks

View File

@ -5,7 +5,7 @@ import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system" import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate" import { Truncate } from "../tool/truncate-effect"
import { Auth } from "../auth" import { Auth } from "../auth"
import { ProviderTransform } from "../provider/transform" import { ProviderTransform } from "../provider/transform"

View File

@ -1,7 +1,7 @@
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Duration, Effect, Match, Option } from "effect" import { Duration, Effect, Match, Option } from "effect"
import { UI } from "../ui" import { UI } from "../ui"
import { runtime } from "@/effect/runtime" import { run } from "@/effect/run"
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect" import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect"
import { type AccountError } from "@/account/schema" import { type AccountError } from "@/account/schema"
import * as Prompt from "../effect/prompt" import * as Prompt from "../effect/prompt"
@ -160,7 +160,7 @@ export const LoginCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await runtime.runPromise(loginEffect(args.url)) await run(loginEffect(args.url))
}, },
}) })
@ -174,7 +174,7 @@ export const LogoutCommand = cmd({
}), }),
async handler(args) { async handler(args) {
UI.empty() UI.empty()
await runtime.runPromise(logoutEffect(args.email)) await run(logoutEffect(args.email))
}, },
}) })
@ -183,7 +183,7 @@ export const SwitchCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await runtime.runPromise(switchEffect()) await run(switchEffect())
}, },
}) })
@ -192,7 +192,7 @@ export const OrgsCommand = cmd({
describe: false, describe: false,
async handler() { async handler() {
UI.empty() UI.empty()
await runtime.runPromise(orgsEffect()) await run(orgsEffect())
}, },
}) })

View File

@ -7,7 +7,7 @@ import type { MessageV2 } from "../../../session/message-v2"
import { MessageID, PartID } from "../../../session/schema" import { MessageID, PartID } from "../../../session/schema"
import { ToolRegistry } from "../../../tool/registry" import { ToolRegistry } from "../../../tool/registry"
import { Instance } from "../../../project/instance" import { Instance } from "../../../project/instance"
import { PermissionNext } from "../../../permission" import { Permission as PermissionNext } from "../../../permission/service"
import { iife } from "../../../util/iife" import { iife } from "../../../util/iife"
import { bootstrap } from "../../bootstrap" import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd" import { cmd } from "../cmd"

View File

@ -11,7 +11,7 @@ import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission" import { Permission as PermissionNext } from "../../permission/service"
import { Tool } from "../../tool/tool" import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob" import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep" import { GrepTool } from "../../tool/grep"

View File

@ -1,20 +1,9 @@
import { runInstance } from "@/effect/run" import { runInstance } from "@/effect/run"
import { File as S } from "./service" import type { File as S } from "./service"
const svc = () => import("./service").then((m) => m.File.Service) const svc = () => import("./service").then((m) => m.File.Service)
export namespace File { export namespace File {
export const Info = S.Info
export type Info = S.Info
export const Node = S.Node
export type Node = S.Node
export const Content = S.Content
export type Content = S.Content
export const Event = S.Event
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export async function init() { export async function init() {
return runInstance((await svc()).use((s) => s.init())) return runInstance((await svc()).use((s) => s.init()))
} }
@ -23,7 +12,7 @@ export namespace File {
return runInstance((await svc()).use((s) => s.status())) return runInstance((await svc()).use((s) => s.status()))
} }
export async function read(file: string): Promise<Content> { export async function read(file: string): Promise<S.Content> {
return runInstance((await svc()).use((s) => s.read(file))) return runInstance((await svc()).use((s) => s.read(file)))
} }

View File

@ -1,15 +1,9 @@
import { runInstance } from "@/effect/run" import { runInstance } from "@/effect/run"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { FileTime as S } from "./time-service"
const svc = () => import("./time-service").then((m) => m.FileTime.Service) const svc = () => import("./time-service").then((m) => m.FileTime.Service)
export namespace FileTime { export namespace FileTime {
export type Stamp = S.Stamp
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export async function read(sessionID: SessionID, file: string) { export async function read(sessionID: SessionID, file: string) {
return runInstance((await svc()).use((s) => s.read(sessionID, file))) return runInstance((await svc()).use((s) => s.read(sessionID, file)))
} }

View File

@ -1,39 +1,14 @@
import { runInstance } from "@/effect/run" import { runInstance } from "@/effect/run"
import { Permission as S } from "./service" import type { Permission } from "./service"
const svc = () => import("./service").then((m) => m.Permission.Service) const svc = () => import("./service").then((m) => m.Permission.Service)
export namespace PermissionNext { export namespace PermissionNext {
export const Action = S.Action export async function ask(input: Permission.AskInput) {
export type Action = S.Action
export const Rule = S.Rule
export type Rule = S.Rule
export type Ruleset = S.Ruleset
export const Request = S.Request
export type Request = S.Request
export const Reply = S.Reply
export type Reply = S.Reply
export const Approval = S.Approval
export const Event = S.Event
export const RejectedError = S.RejectedError
export const CorrectedError = S.CorrectedError
export const DeniedError = S.DeniedError
export type Error = S.Error
export const AskInput = S.AskInput
export const ReplyInput = S.ReplyInput
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export const evaluate = S.evaluate
export const fromConfig = S.fromConfig
export const merge = S.merge
export const disabled = S.disabled
export async function ask(input: S.AskInput) {
return runInstance((await svc()).use((s) => s.ask(input))) return runInstance((await svc()).use((s) => s.ask(input)))
} }
export async function reply(input: S.ReplyInput) { export async function reply(input: Permission.ReplyInput) {
return runInstance((await svc()).use((s) => s.reply(input))) return runInstance((await svc()).use((s) => s.reply(input)))
} }

View File

@ -1,46 +1,22 @@
import { runInstance } from "@/effect/run" import { runInstance } from "@/effect/run"
import { fn } from "@/util/fn" import type { ProviderAuth as S } from "./auth-service"
import { ProviderID } from "./schema"
import z from "zod"
import { ProviderAuth as S } from "./auth-service"
const svc = () => import("./auth-service").then((m) => m.ProviderAuth.Service) const svc = () => import("./auth-service").then((m) => m.ProviderAuth.Service)
export namespace ProviderAuth { export namespace ProviderAuth {
export const Method = S.Method
export type Method = S.Method
export const Authorization = S.Authorization
export type Authorization = S.Authorization
export const OauthMissing = S.OauthMissing
export const OauthCodeMissing = S.OauthCodeMissing
export const OauthCallbackFailed = S.OauthCallbackFailed
export const ValidationFailed = S.ValidationFailed
export type Error = S.Error
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export const defaultLayer = S.defaultLayer
export async function methods() { export async function methods() {
return runInstance((await svc()).use((s) => s.methods())) return runInstance((await svc()).use((s) => s.methods()))
} }
export const authorize = fn( export async function authorize(input: {
z.object({ providerID: string
providerID: ProviderID.zod, method: number
method: z.number(), inputs?: Record<string, string>
inputs: z.record(z.string(), z.string()).optional(), }): Promise<S.Authorization | undefined> {
}), return runInstance((await svc()).use((s) => s.authorize(input as any)))
async (input): Promise<Authorization | undefined> => }
runInstance((await svc()).use((s) => s.authorize(input))),
)
export const callback = fn( export async function callback(input: { providerID: string; method: number; code?: string }) {
z.object({ return runInstance((await svc()).use((s) => s.callback(input as any)))
providerID: ProviderID.zod, }
method: z.number(),
code: z.string().optional(),
}),
async (input) => runInstance((await svc()).use((s) => s.callback(input))),
)
} }

View File

@ -1,36 +1,20 @@
import { runInstance } from "@/effect/run" import { runInstance } from "@/effect/run"
import type { MessageID, SessionID } from "@/session/schema" import type { MessageID, SessionID } from "@/session/schema"
import type { QuestionID } from "./schema" import type { QuestionID } from "./schema"
import { Question as S } from "./service" import type { Question as S } from "./service"
const svc = () => import("./service").then((m) => m.Question.Service) const svc = () => import("./service").then((m) => m.Question.Service)
export namespace Question { export namespace Question {
export const Info = S.Info
export type Info = S.Info
export const Request = S.Request
export type Request = S.Request
export const Answer = S.Answer
export type Answer = S.Answer
export const Reply = S.Reply
export type Reply = S.Reply
export const Option = S.Option
export type Option = S.Option
export const Event = S.Event
export const RejectedError = S.RejectedError
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export async function ask(input: { export async function ask(input: {
sessionID: SessionID sessionID: SessionID
questions: Info[] questions: S.Info[]
tool?: { messageID: MessageID; callID: string } tool?: { messageID: MessageID; callID: string }
}): Promise<Answer[]> { }): Promise<S.Answer[]> {
return runInstance((await svc()).use((s) => s.ask(input))) return runInstance((await svc()).use((s) => s.ask(input)))
} }
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) { export async function reply(input: { requestID: QuestionID; answers: S.Answer[] }) {
return runInstance((await svc()).use((s) => s.reply(input))) return runInstance((await svc()).use((s) => s.reply(input)))
} }

View File

@ -14,6 +14,7 @@ import { Todo } from "../../session/todo"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { Snapshot } from "@/snapshot/service" import { Snapshot } from "@/snapshot/service"
import { Log } from "../../util/log" import { Log } from "../../util/log"
import { Permission } from "@/permission/service"
import { PermissionNext } from "@/permission" import { PermissionNext } from "@/permission"
import { PermissionID } from "@/permission/schema" import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
@ -1010,7 +1011,7 @@ export const SessionRoutes = lazy(() =>
permissionID: PermissionID.zod, permissionID: PermissionID.zod,
}), }),
), ),
validator("json", z.object({ response: PermissionNext.Reply })), validator("json", z.object({ response: Permission.Reply })),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
PermissionNext.reply({ PermissionNext.reply({

View File

@ -13,7 +13,7 @@ import { Format } from "../format"
import { TuiRoutes } from "./routes/tui" import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs" import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime" import { runInstance as runPromiseInstance } from "@/effect/run"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill as SkillService } from "../skill/service" import { Skill as SkillService } from "../skill/service"
import { Skill } from "../skill" import { Skill } from "../skill"

View File

@ -12,7 +12,8 @@ import type { Provider } from "@/provider/provider"
import { LLM } from "./llm" import { LLM } from "./llm"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission" import { Permission as PermissionNext } from "@/permission/service"
import { PermissionNext as PermissionNextApi } from "@/permission"
import { Question } from "@/question/service" import { Question } from "@/question/service"
import { PartID } from "./schema" import { PartID } from "./schema"
import type { SessionID, MessageID } from "./schema" import type { SessionID, MessageID } from "./schema"
@ -163,7 +164,7 @@ export namespace SessionProcessor {
) )
) { ) {
const agent = await Agent.get(input.assistantMessage.agent) const agent = await Agent.get(input.assistantMessage.agent)
await PermissionNext.ask({ await PermissionNextApi.ask({
permission: "doom_loop", permission: "doom_loop",
patterns: [value.toolName], patterns: [value.toolName],
sessionID: input.assistantMessage.sessionID, sessionID: input.assistantMessage.sessionID,

View File

@ -41,7 +41,8 @@ import { fn } from "@/util/fn"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task" import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool" import { Tool } from "@/tool/tool"
import { PermissionNext } from "@/permission" import { Permission as PermissionNext } from "@/permission/service"
import { PermissionNext as PermissionNextApi } from "@/permission"
import { SessionStatus } from "./status" import { SessionStatus } from "./status"
import { LLM } from "./llm" import { LLM } from "./llm"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
@ -437,7 +438,7 @@ export namespace SessionPrompt {
} satisfies MessageV2.ToolPart)) as MessageV2.ToolPart } satisfies MessageV2.ToolPart)) as MessageV2.ToolPart
}, },
async ask(req) { async ask(req) {
await PermissionNext.ask({ await PermissionNextApi.ask({
...req, ...req,
sessionID: sessionID, sessionID: sessionID,
ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []), ruleset: PermissionNext.merge(taskAgent.permission, session.permission ?? []),
@ -781,7 +782,7 @@ export namespace SessionPrompt {
} }
}, },
async ask(req) { async ask(req) {
await PermissionNext.ask({ await PermissionNextApi.ask({
...req, ...req,
sessionID: input.session.id, sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId }, tool: { messageID: input.processor.message.id, callID: options.toolCallId },

View File

@ -1,5 +1,6 @@
import z from "zod" import z from "zod"
import { SessionID, MessageID, PartID } from "./schema" import { SessionID, MessageID, PartID } from "./schema"
import { Snapshot as SnapshotService } from "../snapshot/service"
import { Snapshot } from "../snapshot" import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Session } from "." import { Session } from "."
@ -28,7 +29,7 @@ export namespace SessionRevert {
const session = await Session.get(input.sessionID) const session = await Session.get(input.sessionID)
let revert: Session.Info["revert"] let revert: Session.Info["revert"]
const patches: Snapshot.Patch[] = [] const patches: SnapshotService.Patch[] = []
for (const msg of all) { for (const msg of all) {
if (msg.info.role === "user") lastUser = msg.info if (msg.info.role === "user") lastUser = msg.info
const remaining = [] const remaining = []

View File

@ -5,6 +5,7 @@ import { Session } from "."
import { MessageV2 } from "./message-v2" import { MessageV2 } from "./message-v2"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { SessionID, MessageID } from "./schema" import { SessionID, MessageID } from "./schema"
import { Snapshot as SnapshotService } from "@/snapshot/service"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage" import { Storage } from "@/storage/storage"
@ -126,7 +127,7 @@ export namespace SessionSummary {
messageID: MessageID.zod.optional(), messageID: MessageID.zod.optional(),
}), }),
async (input) => { async (input) => {
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => []) const diffs = await Storage.read<SnapshotService.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
const next = diffs.map((item) => { const next = diffs.map((item) => {
const file = unquoteGitPath(item.file) const file = unquoteGitPath(item.file)
if (file === item.file) return item if (file === item.file) return item

View File

@ -13,6 +13,7 @@ import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent" import type { Agent } from "@/agent/agent"
import { Permission as PermissionNext } from "@/permission/service" import { Permission as PermissionNext } from "@/permission/service"
import { Skill } from "@/skill" import { Skill } from "@/skill"
import { Skill as SkillService } from "@/skill/service"
export namespace SystemPrompt { export namespace SystemPrompt {
export function provider(model: Provider.Model) { export function provider(model: Provider.Model) {
@ -62,7 +63,7 @@ export namespace SystemPrompt {
"Use the skill tool to load a skill when a task matches its description.", "Use the skill tool to load a skill when a task matches its description.",
// the agents seem to ingest the information about skills a bit better if we present a more verbose // the agents seem to ingest the information about skills a bit better if we present a more verbose
// version of them here and a less verbose version in tool description, rather than vice versa. // version of them here and a less verbose version in tool description, rather than vice versa.
Skill.fmt(list, { verbose: true }), SkillService.fmt(list, { verbose: true }),
].join("\n") ].join("\n")
} }
} }

View File

@ -2,12 +2,8 @@ import type { Agent } from "@/agent/agent"
import { runInstance } from "@/effect/run" import { runInstance } from "@/effect/run"
const svc = () => import("./service").then((m) => m.Skill.Service) const svc = () => import("./service").then((m) => m.Skill.Service)
const mod = () => import("./service").then((m) => m.Skill)
export namespace Skill { export namespace Skill {
export type Info = import("./service").Skill.Info
export type Interface = import("./service").Skill.Interface
export async function get(name: string) { export async function get(name: string) {
return runInstance((await svc()).use((s) => s.get(name))) return runInstance((await svc()).use((s) => s.get(name)))
} }
@ -23,8 +19,4 @@ export namespace Skill {
export async function available(agent?: Agent.Info) { export async function available(agent?: Agent.Info) {
return runInstance((await svc()).use((s) => s.available(agent))) return runInstance((await svc()).use((s) => s.available(agent)))
} }
export async function fmt(list: Info[], opts: { verbose: boolean }) {
return (await mod()).fmt(list, opts)
}
} }

View File

@ -1,18 +1,9 @@
import { runInstance } from "@/effect/run" import { runInstance } from "@/effect/run"
import { Snapshot as S } from "./service" import type { Snapshot as S } from "./service"
const svc = () => import("./service").then((m) => m.Snapshot.Service) const svc = () => import("./service").then((m) => m.Snapshot.Service)
export namespace Snapshot { export namespace Snapshot {
export const Patch = S.Patch
export type Patch = S.Patch
export const FileDiff = S.FileDiff
export type FileDiff = S.FileDiff
export type Interface = S.Interface
export const Service = S.Service
export const layer = S.layer
export const defaultLayer = S.defaultLayer
export async function cleanup() { export async function cleanup() {
return runInstance((await svc()).use((s) => s.cleanup())) return runInstance((await svc()).use((s) => s.cleanup()))
} }
@ -29,7 +20,7 @@ export namespace Snapshot {
return runInstance((await svc()).use((s) => s.restore(snapshot))) return runInstance((await svc()).use((s) => s.restore(snapshot)))
} }
export async function revert(patches: Patch[]) { export async function revert(patches: S.Patch[]) {
return runInstance((await svc()).use((s) => s.revert(patches))) return runInstance((await svc()).use((s) => s.revert(patches)))
} }

View File

@ -15,7 +15,7 @@ import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate" import { Truncate } from "./truncate-effect"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000 const MAX_METADATA_LENGTH = 30_000

View File

@ -1,6 +1,7 @@
import z from "zod" import z from "zod"
import path from "path" import path from "path"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Question as QuestionService } from "../question/service"
import { Question } from "../question" import { Question } from "../question"
import { Session } from "../session" import { Session } from "../session"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
@ -39,7 +40,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
}) })
const answer = answers[0]?.[0] const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError() if (answer === "No") throw new QuestionService.RejectedError()
const model = await getLastModel(ctx.sessionID) const model = await getLastModel(ctx.sessionID)
@ -97,7 +98,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {
const answer = answers[0]?.[0] const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError() if (answer === "No") throw new QuestionService.RejectedError()
const model = await getLastModel(ctx.sessionID) const model = await getLastModel(ctx.sessionID)

View File

@ -3,6 +3,7 @@ import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Skill as SkillService } from "../skill/service"
import { Ripgrep } from "../file/ripgrep" import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
@ -24,7 +25,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
"The following skills provide specialized sets of instructions for particular tasks", "The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:", "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"", "",
Skill.fmt(list, { verbose: false }), SkillService.fmt(list, { verbose: false }),
].join("\n") ].join("\n")
const examples = list const examples = list

View File

@ -1,18 +1,10 @@
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import { runtime } from "@/effect/runtime" import { run } from "@/effect/run"
import { Truncate as S } from "./truncate-effect"
const svc = () => import("./truncate-effect").then((m) => m.Truncate.Service)
export namespace Truncate { export namespace Truncate {
export const MAX_LINES = S.MAX_LINES export async function output(text: string, options: any = {}, agent?: Agent.Info) {
export const MAX_BYTES = S.MAX_BYTES return run((await svc()).use((s) => s.output(text, options, agent)))
export const DIR = S.DIR
export const GLOB = S.GLOB
export type Result = S.Result
export type Options = S.Options
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
return runtime.runPromise(S.Service.use((s) => s.output(text, options, agent)))
} }
} }

View File

@ -3,12 +3,12 @@ import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { PermissionNext } from "../../src/permission" import { Permission } from "../../src/permission/service"
// Helper to evaluate permission for a tool with wildcard pattern // 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 if (!agent) return undefined
return PermissionNext.evaluate(permission, "*", agent.permission).action return Permission.evaluate(permission, "*", agent.permission).action
} }
test("returns default native agents when no config", async () => { test("returns default native agents when no config", async () => {
@ -54,7 +54,7 @@ test("plan agent denies edits except .opencode/plans/*", async () => {
// Wildcard is denied // Wildcard is denied
expect(evalPerm(plan, "edit")).toBe("deny") expect(evalPerm(plan, "edit")).toBe("deny")
// But specific path is allowed // 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")
}, },
}) })
}) })
@ -76,15 +76,15 @@ test("explore agent denies edit and write", async () => {
}) })
test("explore agent asks for external directories and allows Truncate.GLOB", async () => { test("explore agent asks for external directories and allows Truncate.GLOB", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncate-effect")
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const explore = await Agent.get("explore") const explore = await Agent.get("explore")
expect(explore).toBeDefined() expect(explore).toBeDefined()
expect(PermissionNext.evaluate("external_directory", "/some/other/path", explore!.permission).action).toBe("ask") expect(Permission.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", Truncate.GLOB, explore!.permission).action).toBe("allow")
}, },
}) })
}) })
@ -216,7 +216,7 @@ test("agent permission config merges with defaults", async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(build).toBeDefined() expect(build).toBeDefined()
// Specific pattern is denied // 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 // Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow") expect(evalPerm(build, "edit")).toBe("allow")
}, },
@ -489,7 +489,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
}) })
test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => { test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncate-effect")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
permission: { permission: {
@ -501,15 +501,15 @@ test("Truncate.GLOB is allowed even when user denies external_directory globally
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.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", "/some/other/path", build!.permission).action).toBe("deny")
}, },
}) })
}) })
test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => { test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncate-effect")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
agent: { agent: {
@ -525,15 +525,15 @@ test("Truncate.GLOB is allowed even when user denies external_directory per-agen
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow") expect(Permission.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny") expect(Permission.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", "/some/other/path", build!.permission).action).toBe("deny")
}, },
}) })
}) })
test("explicit Truncate.GLOB deny is respected", async () => { test("explicit Truncate.GLOB deny is respected", async () => {
const { Truncate } = await import("../../src/tool/truncate") const { Truncate } = await import("../../src/tool/truncate-effect")
await using tmp = await tmpdir({ await using tmp = await tmpdir({
config: { config: {
permission: { permission: {
@ -548,8 +548,8 @@ test("explicit Truncate.GLOB deny is respected", async () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const build = await Agent.get("build") const build = await Agent.get("build")
expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny") expect(Permission.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.DIR, build!.permission).action).toBe("deny")
}, },
}) })
}) })
@ -582,7 +582,7 @@ description: Permission skill.
const build = await Agent.get("build") const build = await Agent.get("build")
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill") const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
const target = path.join(skillDir, "reference", "notes.md") 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 { } finally {

View File

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

View File

@ -4,9 +4,9 @@ import { runtime, runPromiseInstance } from "../../src/effect/runtime"
import { Auth } from "../../src/auth/effect" import { Auth } from "../../src/auth/effect"
import { Instances } from "../../src/effect/instances" import { Instances } from "../../src/effect/instances"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { ProviderAuth } from "../../src/provider/auth" import { ProviderAuth } from "../../src/provider/auth-service"
import { Vcs } from "../../src/project/vcs" import { Vcs } from "../../src/project/vcs"
import { Question } from "../../src/question" import { Question } from "../../src/question/service"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
/** /**

View File

@ -2,7 +2,7 @@ import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance" import { withServices } from "../fixture/instance"
import { Format } from "../../src/format" import { Format } from "../../src/format/service"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
describe("Format", () => { describe("Format", () => {

View File

@ -1,11 +1,11 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { PermissionNext } from "../src/permission" import { Permission } from "../src/permission/service"
import { Config } from "../src/config/config" import { Config } from "../src/config/config"
import { Instance } from "../src/project/instance" import { Instance } from "../src/project/instance"
import { tmpdir } from "./fixture/fixture" import { tmpdir } from "./fixture/fixture"
describe("PermissionNext.evaluate for permission.task", () => { describe("Permission.evaluate for permission.task", () => {
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]) => ({ Object.entries(rules).map(([pattern, action]) => ({
permission: "task", permission: "task",
pattern, pattern,
@ -13,42 +13,42 @@ describe("PermissionNext.evaluate for permission.task", () => {
})) }))
test("returns ask when no match (default)", () => { 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", () => { test("returns deny for explicit deny", () => {
const ruleset = createRuleset({ "code-reviewer": "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", () => { test("returns allow for explicit allow", () => {
const ruleset = createRuleset({ "code-reviewer": "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", () => { test("returns ask for explicit ask", () => {
const ruleset = createRuleset({ "code-reviewer": "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", () => { test("matches wildcard patterns with deny", () => {
const ruleset = createRuleset({ "orchestrator-*": "deny" }) const ruleset = createRuleset({ "orchestrator-*": "deny" })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
}) })
test("matches wildcard patterns with allow", () => { test("matches wildcard patterns with allow", () => {
const ruleset = createRuleset({ "orchestrator-*": "allow" }) const ruleset = createRuleset({ "orchestrator-*": "allow" })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("allow")
}) })
test("matches wildcard patterns with ask", () => { test("matches wildcard patterns with ask", () => {
const ruleset = createRuleset({ "orchestrator-*": "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" }) 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)", () => { test("later rules take precedence (last match wins)", () => {
@ -56,22 +56,22 @@ describe("PermissionNext.evaluate for permission.task", () => {
"orchestrator-*": "deny", "orchestrator-*": "deny",
"orchestrator-fast": "allow", "orchestrator-fast": "allow",
}) })
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "orchestrator-slow", ruleset).action).toBe("deny")
}) })
test("matches global wildcard", () => { test("matches global wildcard", () => {
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow") expect(Permission.evaluate("task", "any-agent", createRuleset({ "*": "allow" })).action).toBe("allow")
expect(PermissionNext.evaluate("task", "any-agent", createRuleset({ "*": "deny" })).action).toBe("deny") expect(Permission.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({ "*": "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. // 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 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`. // 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]) => ({ Object.entries(rules).map(([pattern, action]) => ({
permission: "task", permission: "task",
pattern, pattern,
@ -85,7 +85,7 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "allow", "orchestrator-*": "allow",
"*": "deny", "*": "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" // The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true) expect(disabled.has("task")).toBe(true)
}) })
@ -95,14 +95,14 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "ask", "orchestrator-*": "ask",
"*": "deny", "*": "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" // The task tool IS disabled because there's a pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(true) expect(disabled.has("task")).toBe(true)
}) })
test("task tool is disabled when global deny pattern exists", () => { test("task tool is disabled when global deny pattern exists", () => {
const ruleset = createRuleset({ "*": "deny" }) const ruleset = createRuleset({ "*": "deny" })
const disabled = PermissionNext.disabled(["task"], ruleset) const disabled = Permission.disabled(["task"], ruleset)
expect(disabled.has("task")).toBe(true) expect(disabled.has("task")).toBe(true)
}) })
@ -113,13 +113,13 @@ describe("PermissionNext.disabled for task tool", () => {
"orchestrator-*": "deny", "orchestrator-*": "deny",
general: "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" // The task tool is NOT disabled because no rule has pattern: "*" with action: "deny"
expect(disabled.has("task")).toBe(false) expect(disabled.has("task")).toBe(false)
}) })
test("task tool is enabled when no task rules exist (default ask)", () => { 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) expect(disabled.has("task")).toBe(false)
}) })
@ -129,7 +129,7 @@ describe("PermissionNext.disabled for task tool", () => {
"*": "deny", "*": "deny",
"orchestrator-coder": "allow", "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 // The disabled() function uses findLast and checks if the last matching rule
// has pattern: "*" and action: "deny". In this case, the last rule matching // has pattern: "*" and action: "deny". In this case, the last rule matching
// "task" permission has pattern "orchestrator-coder", not "*", so not disabled // "task" permission has pattern "orchestrator-coder", not "*", so not disabled
@ -155,11 +155,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() 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 // general and orchestrator-fast should be allowed, code-reviewer denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
}, },
}) })
}) })
@ -180,11 +180,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() 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 // general and code-reviewer should be ask, orchestrator-* denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("ask") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
expect(PermissionNext.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("deny")
}, },
}) })
}) })
@ -205,11 +205,11 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask" // 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 +232,18 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() const config = await Config.get()
const ruleset = PermissionNext.fromConfig(config.permission ?? {}) const ruleset = Permission.fromConfig(config.permission ?? {})
// Verify task permissions // Verify task permissions
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("allow") expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Verify other tool permissions // Verify other tool permissions
expect(PermissionNext.evaluate("bash", "*", ruleset).action).toBe("allow") expect(Permission.evaluate("bash", "*", ruleset).action).toBe("allow")
expect(PermissionNext.evaluate("edit", "*", ruleset).action).toBe("ask") expect(Permission.evaluate("edit", "*", ruleset).action).toBe("ask")
// Verify disabled tools // 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("bash")).toBe(false)
expect(disabled.has("edit")).toBe(false) expect(disabled.has("edit")).toBe(false)
// task is NOT disabled because disabled() uses findLast, and the last rule // task is NOT disabled because disabled() uses findLast, and the last rule
@ -270,16 +270,16 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() 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 // Last matching rule wins - "*" deny is last, so all agents are denied
expect(PermissionNext.evaluate("task", "general", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "code-reviewer", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
expect(PermissionNext.evaluate("task", "unknown", ruleset).action).toBe("deny") expect(Permission.evaluate("task", "unknown", ruleset).action).toBe("deny")
// Since "*": "deny" is the last rule, disabled() finds it with findLast // Since "*": "deny" is the last rule, disabled() finds it with findLast
// and sees pattern: "*" with action: "deny", so task is disabled // 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) expect(disabled.has("task")).toBe(true)
}, },
}) })
@ -301,17 +301,17 @@ describe("permission.task with real config files", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const config = await Config.get() 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 // 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 // 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" // 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: "*" // 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) // 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) expect(disabled.has("task")).toBe(false)
}, },
}) })

View File

@ -5,7 +5,7 @@ import { Bus } from "../../src/bus"
import { runtime } from "../../src/effect/runtime" import { runtime } from "../../src/effect/runtime"
import { Instances } from "../../src/effect/instances" import { Instances } from "../../src/effect/instances"
import { PermissionNext } from "../../src/permission" import { PermissionNext } from "../../src/permission"
import { PermissionNext as S } from "../../src/permission" import { Permission as Svc } from "../../src/permission/service"
import { PermissionID } from "../../src/permission/schema" import { PermissionID } from "../../src/permission/schema"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@ -37,12 +37,12 @@ async function waitForPending(count: number) {
// fromConfig tests // fromConfig tests
test("fromConfig - string value becomes wildcard rule", () => { test("fromConfig - string value becomes wildcard rule", () => {
const result = PermissionNext.fromConfig({ bash: "allow" }) const result = Svc.fromConfig({ bash: "allow" })
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
}) })
test("fromConfig - object value converts to rules array", () => { test("fromConfig - object value converts to rules array", () => {
const result = PermissionNext.fromConfig({ bash: { "*": "allow", rm: "deny" } }) const result = Svc.fromConfig({ bash: { "*": "allow", rm: "deny" } })
expect(result).toEqual([ expect(result).toEqual([
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "rm", action: "deny" },
@ -50,7 +50,7 @@ test("fromConfig - object value converts to rules array", () => {
}) })
test("fromConfig - mixed string and object values", () => { test("fromConfig - mixed string and object values", () => {
const result = PermissionNext.fromConfig({ const result = Svc.fromConfig({
bash: { "*": "allow", rm: "deny" }, bash: { "*": "allow", rm: "deny" },
edit: "allow", edit: "allow",
webfetch: "ask", webfetch: "ask",
@ -64,51 +64,51 @@ test("fromConfig - mixed string and object values", () => {
}) })
test("fromConfig - empty object", () => { test("fromConfig - empty object", () => {
const result = PermissionNext.fromConfig({}) const result = Svc.fromConfig({})
expect(result).toEqual([]) expect(result).toEqual([])
}) })
test("fromConfig - expands tilde to home directory", () => { test("fromConfig - expands tilde to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) const result = Svc.fromConfig({ external_directory: { "~/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
}) })
test("fromConfig - expands $HOME to home directory", () => { test("fromConfig - expands $HOME to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) const result = Svc.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: `${os.homedir()}/projects/*`, action: "allow" }])
}) })
test("fromConfig - expands $HOME without trailing slash", () => { test("fromConfig - expands $HOME without trailing slash", () => {
const result = PermissionNext.fromConfig({ external_directory: { $HOME: "allow" } }) const result = Svc.fromConfig({ external_directory: { $HOME: "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
}) })
test("fromConfig - does not expand tilde in middle of path", () => { test("fromConfig - does not expand tilde in middle of path", () => {
const result = PermissionNext.fromConfig({ external_directory: { "/some/~/path": "allow" } }) const result = Svc.fromConfig({ external_directory: { "/some/~/path": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
}) })
test("fromConfig - expands exact tilde to home directory", () => { test("fromConfig - expands exact tilde to home directory", () => {
const result = PermissionNext.fromConfig({ external_directory: { "~": "allow" } }) const result = Svc.fromConfig({ external_directory: { "~": "allow" } })
expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }])
}) })
test("evaluate - matches expanded tilde pattern", () => { test("evaluate - matches expanded tilde pattern", () => {
const ruleset = PermissionNext.fromConfig({ external_directory: { "~/projects/*": "allow" } }) const ruleset = Svc.fromConfig({ external_directory: { "~/projects/*": "allow" } })
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) const result = Svc.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
test("evaluate - matches expanded $HOME pattern", () => { test("evaluate - matches expanded $HOME pattern", () => {
const ruleset = PermissionNext.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } }) const ruleset = Svc.fromConfig({ external_directory: { "$HOME/projects/*": "allow" } })
const result = PermissionNext.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset) const result = Svc.evaluate("external_directory", `${os.homedir()}/projects/file.txt`, ruleset)
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
// merge tests // merge tests
test("merge - simple concatenation", () => { test("merge - simple concatenation", () => {
const result = PermissionNext.merge( const result = Svc.merge(
[{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "*", action: "deny" }], [{ permission: "bash", pattern: "*", action: "deny" }],
) )
@ -119,7 +119,7 @@ test("merge - simple concatenation", () => {
}) })
test("merge - adds new permission", () => { test("merge - adds new permission", () => {
const result = PermissionNext.merge( const result = Svc.merge(
[{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "edit", pattern: "*", action: "deny" }], [{ permission: "edit", pattern: "*", action: "deny" }],
) )
@ -130,7 +130,7 @@ test("merge - adds new permission", () => {
}) })
test("merge - concatenates rules for same permission", () => { test("merge - concatenates rules for same permission", () => {
const result = PermissionNext.merge( const result = Svc.merge(
[{ permission: "bash", pattern: "foo", action: "ask" }], [{ permission: "bash", pattern: "foo", action: "ask" }],
[{ permission: "bash", pattern: "*", action: "deny" }], [{ permission: "bash", pattern: "*", action: "deny" }],
) )
@ -141,7 +141,7 @@ test("merge - concatenates rules for same permission", () => {
}) })
test("merge - multiple rulesets", () => { test("merge - multiple rulesets", () => {
const result = PermissionNext.merge( const result = Svc.merge(
[{ permission: "bash", pattern: "*", action: "allow" }], [{ permission: "bash", pattern: "*", action: "allow" }],
[{ permission: "bash", pattern: "rm", action: "ask" }], [{ permission: "bash", pattern: "rm", action: "ask" }],
[{ permission: "edit", pattern: "*", action: "allow" }], [{ permission: "edit", pattern: "*", action: "allow" }],
@ -154,12 +154,12 @@ test("merge - multiple rulesets", () => {
}) })
test("merge - empty ruleset does nothing", () => { test("merge - empty ruleset does nothing", () => {
const result = PermissionNext.merge([{ permission: "bash", pattern: "*", action: "allow" }], []) const result = Svc.merge([{ permission: "bash", pattern: "*", action: "allow" }], [])
expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }]) expect(result).toEqual([{ permission: "bash", pattern: "*", action: "allow" }])
}) })
test("merge - preserves rule order", () => { test("merge - preserves rule order", () => {
const result = PermissionNext.merge( const result = Svc.merge(
[ [
{ permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret/*", action: "deny" }, { permission: "edit", pattern: "src/secret/*", action: "deny" },
@ -175,40 +175,40 @@ test("merge - preserves rule order", () => {
test("merge - config permission overrides default ask", () => { test("merge - config permission overrides default ask", () => {
// Simulates: defaults have "*": "ask", config sets bash: "allow" // Simulates: defaults have "*": "ask", config sets bash: "allow"
const defaults: PermissionNext.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] const defaults: Svc.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const config: Svc.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const merged = PermissionNext.merge(defaults, config) const merged = Svc.merge(defaults, config)
// Config's bash allow should override default ask // Config's bash allow should override default ask
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("allow") expect(Svc.evaluate("bash", "ls", merged).action).toBe("allow")
// Other permissions should still be ask (from defaults) // Other permissions should still be ask (from defaults)
expect(PermissionNext.evaluate("edit", "foo.ts", merged).action).toBe("ask") expect(Svc.evaluate("edit", "foo.ts", merged).action).toBe("ask")
}) })
test("merge - config ask overrides default allow", () => { test("merge - config ask overrides default allow", () => {
// Simulates: defaults have bash: "allow", config sets bash: "ask" // Simulates: defaults have bash: "allow", config sets bash: "ask"
const defaults: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const defaults: Svc.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] const config: Svc.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }]
const merged = PermissionNext.merge(defaults, config) const merged = Svc.merge(defaults, config)
// Config's ask should override default allow // Config's ask should override default allow
expect(PermissionNext.evaluate("bash", "ls", merged).action).toBe("ask") expect(Svc.evaluate("bash", "ls", merged).action).toBe("ask")
}) })
// evaluate tests // evaluate tests
test("evaluate - exact pattern match", () => { test("evaluate - exact pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }]) const result = Svc.evaluate("bash", "rm", [{ permission: "bash", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
test("evaluate - wildcard pattern match", () => { test("evaluate - wildcard pattern match", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }]) const result = Svc.evaluate("bash", "rm", [{ permission: "bash", pattern: "*", action: "allow" }])
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
test("evaluate - last matching rule wins", () => { test("evaluate - last matching rule wins", () => {
const result = PermissionNext.evaluate("bash", "rm", [ const result = Svc.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "rm", action: "deny" },
]) ])
@ -216,7 +216,7 @@ test("evaluate - last matching rule wins", () => {
}) })
test("evaluate - last matching rule wins (wildcard after specific)", () => { test("evaluate - last matching rule wins (wildcard after specific)", () => {
const result = PermissionNext.evaluate("bash", "rm", [ const result = Svc.evaluate("bash", "rm", [
{ permission: "bash", pattern: "rm", action: "deny" }, { permission: "bash", pattern: "rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
]) ])
@ -224,14 +224,12 @@ test("evaluate - last matching rule wins (wildcard after specific)", () => {
}) })
test("evaluate - glob pattern match", () => { test("evaluate - glob pattern match", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [ const result = Svc.evaluate("edit", "src/foo.ts", [{ permission: "edit", pattern: "src/*", action: "allow" }])
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
test("evaluate - last matching glob wins", () => { test("evaluate - last matching glob wins", () => {
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ const result = Svc.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "deny" },
{ permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/components/*", action: "allow" },
]) ])
@ -240,7 +238,7 @@ test("evaluate - last matching glob wins", () => {
test("evaluate - order matters for specificity", () => { test("evaluate - order matters for specificity", () => {
// If more specific rule comes first, later wildcard overrides it // If more specific rule comes first, later wildcard overrides it
const result = PermissionNext.evaluate("edit", "src/components/Button.tsx", [ const result = Svc.evaluate("edit", "src/components/Button.tsx", [
{ permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/components/*", action: "allow" },
{ permission: "edit", pattern: "src/*", action: "deny" }, { permission: "edit", pattern: "src/*", action: "deny" },
]) ])
@ -248,31 +246,27 @@ test("evaluate - order matters for specificity", () => {
}) })
test("evaluate - unknown permission returns ask", () => { test("evaluate - unknown permission returns ask", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", [ const result = Svc.evaluate("unknown_tool", "anything", [{ permission: "bash", pattern: "*", action: "allow" }])
{ permission: "bash", pattern: "*", action: "allow" },
])
expect(result.action).toBe("ask") expect(result.action).toBe("ask")
}) })
test("evaluate - empty ruleset returns ask", () => { test("evaluate - empty ruleset returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", []) const result = Svc.evaluate("bash", "rm", [])
expect(result.action).toBe("ask") expect(result.action).toBe("ask")
}) })
test("evaluate - no matching pattern returns ask", () => { test("evaluate - no matching pattern returns ask", () => {
const result = PermissionNext.evaluate("edit", "etc/passwd", [ const result = Svc.evaluate("edit", "etc/passwd", [{ permission: "edit", pattern: "src/*", action: "allow" }])
{ permission: "edit", pattern: "src/*", action: "allow" },
])
expect(result.action).toBe("ask") expect(result.action).toBe("ask")
}) })
test("evaluate - empty rules array returns ask", () => { test("evaluate - empty rules array returns ask", () => {
const result = PermissionNext.evaluate("bash", "rm", []) const result = Svc.evaluate("bash", "rm", [])
expect(result.action).toBe("ask") expect(result.action).toBe("ask")
}) })
test("evaluate - multiple matching patterns, last wins", () => { test("evaluate - multiple matching patterns, last wins", () => {
const result = PermissionNext.evaluate("edit", "src/secret.ts", [ const result = Svc.evaluate("edit", "src/secret.ts", [
{ permission: "edit", pattern: "*", action: "ask" }, { permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "allow" },
{ permission: "edit", pattern: "src/secret.ts", action: "deny" }, { permission: "edit", pattern: "src/secret.ts", action: "deny" },
@ -281,7 +275,7 @@ test("evaluate - multiple matching patterns, last wins", () => {
}) })
test("evaluate - non-matching patterns are skipped", () => { test("evaluate - non-matching patterns are skipped", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [ const result = Svc.evaluate("edit", "src/foo.ts", [
{ permission: "edit", pattern: "*", action: "ask" }, { permission: "edit", pattern: "*", action: "ask" },
{ permission: "edit", pattern: "test/*", action: "deny" }, { permission: "edit", pattern: "test/*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "allow" },
@ -290,7 +284,7 @@ test("evaluate - non-matching patterns are skipped", () => {
}) })
test("evaluate - exact match at end wins over earlier wildcard", () => { test("evaluate - exact match at end wins over earlier wildcard", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", [ const result = Svc.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
{ permission: "bash", pattern: "/bin/rm", action: "deny" }, { permission: "bash", pattern: "/bin/rm", action: "deny" },
]) ])
@ -298,7 +292,7 @@ test("evaluate - exact match at end wins over earlier wildcard", () => {
}) })
test("evaluate - wildcard at end overrides earlier exact match", () => { test("evaluate - wildcard at end overrides earlier exact match", () => {
const result = PermissionNext.evaluate("bash", "/bin/rm", [ const result = Svc.evaluate("bash", "/bin/rm", [
{ permission: "bash", pattern: "/bin/rm", action: "deny" }, { permission: "bash", pattern: "/bin/rm", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
]) ])
@ -308,24 +302,22 @@ test("evaluate - wildcard at end overrides earlier exact match", () => {
// wildcard permission tests // wildcard permission tests
test("evaluate - wildcard permission matches any permission", () => { test("evaluate - wildcard permission matches any permission", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }]) const result = Svc.evaluate("bash", "rm", [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
test("evaluate - wildcard permission with specific pattern", () => { test("evaluate - wildcard permission with specific pattern", () => {
const result = PermissionNext.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }]) const result = Svc.evaluate("bash", "rm", [{ permission: "*", pattern: "rm", action: "deny" }])
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
test("evaluate - glob permission pattern", () => { test("evaluate - glob permission pattern", () => {
const result = PermissionNext.evaluate("mcp_server_tool", "anything", [ const result = Svc.evaluate("mcp_server_tool", "anything", [{ permission: "mcp_*", pattern: "*", action: "allow" }])
{ permission: "mcp_*", pattern: "*", action: "allow" },
])
expect(result.action).toBe("allow") expect(result.action).toBe("allow")
}) })
test("evaluate - specific permission and wildcard permission combined", () => { test("evaluate - specific permission and wildcard permission combined", () => {
const result = PermissionNext.evaluate("bash", "rm", [ const result = Svc.evaluate("bash", "rm", [
{ permission: "*", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" },
{ permission: "bash", pattern: "*", action: "allow" }, { 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", () => { test("evaluate - wildcard permission does not match when specific exists", () => {
const result = PermissionNext.evaluate("edit", "src/foo.ts", [ const result = Svc.evaluate("edit", "src/foo.ts", [
{ permission: "*", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "src/*", action: "allow" }, { 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", () => { test("evaluate - multiple matching permission patterns combine rules", () => {
const result = PermissionNext.evaluate("mcp_dangerous", "anything", [ const result = Svc.evaluate("mcp_dangerous", "anything", [
{ permission: "*", pattern: "*", action: "ask" }, { permission: "*", pattern: "*", action: "ask" },
{ permission: "mcp_*", pattern: "*", action: "allow" }, { permission: "mcp_*", pattern: "*", action: "allow" },
{ permission: "mcp_dangerous", pattern: "*", action: "deny" }, { 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", () => { test("evaluate - wildcard permission fallback for unknown tool", () => {
const result = PermissionNext.evaluate("unknown_tool", "anything", [ const result = Svc.evaluate("unknown_tool", "anything", [
{ permission: "*", pattern: "*", action: "ask" }, { permission: "*", pattern: "*", action: "ask" },
{ permission: "bash", pattern: "*", action: "allow" }, { 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", () => { test("evaluate - permission patterns sorted by length regardless of object order", () => {
// specific permission listed before wildcard, but specific should still win // specific permission listed before wildcard, but specific should still win
const result = PermissionNext.evaluate("bash", "rm", [ const result = Svc.evaluate("bash", "rm", [
{ permission: "bash", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" },
{ permission: "*", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" },
]) ])
@ -368,22 +360,22 @@ test("evaluate - permission patterns sorted by length regardless of object order
}) })
test("evaluate - merges multiple rulesets", () => { test("evaluate - merges multiple rulesets", () => {
const config: PermissionNext.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const config: Svc.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }]
const approved: PermissionNext.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] const approved: Svc.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }]
// approved comes after config, so rm should be denied // approved comes after config, so rm should be denied
const result = PermissionNext.evaluate("bash", "rm", config, approved) const result = Svc.evaluate("bash", "rm", config, approved)
expect(result.action).toBe("deny") expect(result.action).toBe("deny")
}) })
// disabled tests // disabled tests
test("disabled - returns empty set when all tools allowed", () => { test("disabled - returns empty set when all tools allowed", () => {
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }]) const result = Svc.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "allow" }])
expect(result.size).toBe(0) expect(result.size).toBe(0)
}) })
test("disabled - disables tool when denied", () => { test("disabled - disables tool when denied", () => {
const result = PermissionNext.disabled( const result = Svc.disabled(
["bash", "edit", "read"], ["bash", "edit", "read"],
[ [
{ permission: "*", pattern: "*", action: "allow" }, { 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", () => { test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => {
const result = PermissionNext.disabled( const result = Svc.disabled(
["edit", "write", "apply_patch", "multiedit", "bash"], ["edit", "write", "apply_patch", "multiedit", "bash"],
[ [
{ permission: "*", pattern: "*", action: "allow" }, { 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", () => { test("disabled - does not disable when partially denied", () => {
const result = PermissionNext.disabled( const result = Svc.disabled(
["bash"], ["bash"],
[ [
{ permission: "bash", pattern: "*", action: "allow" }, { 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", () => { test("disabled - does not disable when action is ask", () => {
const result = PermissionNext.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }]) const result = Svc.disabled(["bash", "edit"], [{ permission: "*", pattern: "*", action: "ask" }])
expect(result.size).toBe(0) expect(result.size).toBe(0)
}) })
test("disabled - does not disable when specific allow after wildcard deny", () => { test("disabled - does not disable when specific allow after wildcard deny", () => {
// Tool is NOT disabled because a specific allow after wildcard deny means // Tool is NOT disabled because a specific allow after wildcard deny means
// there's at least some usage allowed // there's at least some usage allowed
const result = PermissionNext.disabled( const result = Svc.disabled(
["bash"], ["bash"],
[ [
{ permission: "bash", pattern: "*", action: "deny" }, { 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", () => { test("disabled - does not disable when wildcard allow after deny", () => {
const result = PermissionNext.disabled( const result = Svc.disabled(
["bash"], ["bash"],
[ [
{ permission: "bash", pattern: "rm *", action: "deny" }, { 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", () => { test("disabled - disables multiple tools", () => {
const result = PermissionNext.disabled( const result = Svc.disabled(
["bash", "edit", "webfetch"], ["bash", "edit", "webfetch"],
[ [
{ permission: "bash", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" },
@ -465,14 +457,14 @@ test("disabled - disables multiple tools", () => {
}) })
test("disabled - wildcard permission denies all tools", () => { test("disabled - wildcard permission denies all tools", () => {
const result = PermissionNext.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }]) const result = Svc.disabled(["bash", "edit", "read"], [{ permission: "*", pattern: "*", action: "deny" }])
expect(result.has("bash")).toBe(true) expect(result.has("bash")).toBe(true)
expect(result.has("edit")).toBe(true) expect(result.has("edit")).toBe(true)
expect(result.has("read")).toBe(true) expect(result.has("read")).toBe(true)
}) })
test("disabled - specific allow overrides wildcard deny", () => { test("disabled - specific allow overrides wildcard deny", () => {
const result = PermissionNext.disabled( const result = Svc.disabled(
["bash", "edit", "read"], ["bash", "edit", "read"],
[ [
{ permission: "*", pattern: "*", action: "deny" }, { permission: "*", pattern: "*", action: "deny" },
@ -518,7 +510,7 @@ test("ask - throws RejectedError when action is deny", async () => {
always: [], always: [],
ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }],
}), }),
).rejects.toBeInstanceOf(PermissionNext.DeniedError) ).rejects.toBeInstanceOf(Svc.DeniedError)
}, },
}) })
}) })
@ -588,8 +580,8 @@ test("ask - publishes asked event", async () => {
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
let seen: PermissionNext.Request | undefined let seen: Svc.Request | undefined
const unsub = Bus.subscribe(PermissionNext.Event.Asked, (event) => { const unsub = Bus.subscribe(Svc.Event.Asked, (event) => {
seen = event.properties seen = event.properties
}) })
@ -672,7 +664,7 @@ test("reply - reject throws RejectedError", async () => {
reply: "reject", reply: "reject",
}) })
await expect(askPromise).rejects.toBeInstanceOf(PermissionNext.RejectedError) await expect(askPromise).rejects.toBeInstanceOf(Svc.RejectedError)
}, },
}) })
}) })
@ -701,7 +693,7 @@ test("reply - reject with message throws CorrectedError", async () => {
}) })
const err = await ask.catch((err) => err) const err = await ask.catch((err) => err)
expect(err).toBeInstanceOf(PermissionNext.CorrectedError) expect(err).toBeInstanceOf(Svc.CorrectedError)
expect(err.message).toContain("Use a safer command") expect(err.message).toContain("Use a safer command")
}, },
}) })
@ -788,8 +780,8 @@ test("reply - reject cancels all pending for same session", async () => {
}) })
// Both should be rejected // Both should be rejected
expect(await result1).toBeInstanceOf(PermissionNext.RejectedError) expect(await result1).toBeInstanceOf(Svc.RejectedError)
expect(await result2).toBeInstanceOf(PermissionNext.RejectedError) expect(await result2).toBeInstanceOf(Svc.RejectedError)
}, },
}) })
}) })
@ -895,10 +887,10 @@ test("reply - publishes replied event", async () => {
| { | {
sessionID: SessionID sessionID: SessionID
requestID: PermissionID requestID: PermissionID
reply: PermissionNext.Reply reply: Svc.Reply
} }
| undefined | undefined
const unsub = Bus.subscribe(PermissionNext.Event.Replied, (event) => { const unsub = Bus.subscribe(Svc.Event.Replied, (event) => {
seen = event.properties seen = event.properties
}) })
@ -949,7 +941,7 @@ test("ask - checks all patterns and stops on first deny", async () => {
{ permission: "bash", pattern: "rm *", action: "deny" }, { permission: "bash", pattern: "rm *", action: "deny" },
], ],
}), }),
).rejects.toBeInstanceOf(PermissionNext.DeniedError) ).rejects.toBeInstanceOf(Svc.DeniedError)
}, },
}) })
}) })
@ -992,7 +984,7 @@ test("ask - should deny even when an earlier pattern is ask", async () => {
(err) => err, (err) => err,
) )
expect(err).toBeInstanceOf(PermissionNext.DeniedError) expect(err).toBeInstanceOf(Svc.DeniedError)
expect(await PermissionNext.list()).toHaveLength(0) expect(await PermissionNext.list()).toHaveLength(0)
}, },
}) })
@ -1005,7 +997,7 @@ test("ask - abort should clear pending request", async () => {
fn: async () => { fn: async () => {
const ctl = new AbortController() const ctl = new AbortController()
const ask = runtime.runPromise( const ask = runtime.runPromise(
S.Service.use((svc) => Svc.Service.use((svc) =>
svc.ask({ svc.ask({
sessionID: SessionID.make("session_test"), sessionID: SessionID.make("session_test"),
permission: "bash", permission: "bash",

View File

@ -1,5 +1,6 @@
import { afterEach, test, expect } from "bun:test" import { afterEach, test, expect } from "bun:test"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Question as QuestionService } from "../../src/question/service"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { QuestionID } from "../../src/question/schema" import { QuestionID } from "../../src/question/schema"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
@ -181,7 +182,7 @@ test("reject - throws RejectedError", async () => {
const pending = await Question.list() const pending = await Question.list()
await Question.reject(pending[0].id) await Question.reject(pending[0].id)
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError) await expect(askPromise).rejects.toBeInstanceOf(QuestionService.RejectedError)
}, },
}) })
}) })

View File

@ -4,7 +4,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import type { Provider } from "../../src/provider/provider" import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionID, MessageID, PartID } from "../../src/session/schema" import { SessionID, MessageID, PartID } from "../../src/session/schema"
import { Question } from "../../src/question" import { Question } from "../../src/question/service"
const sessionID = SessionID.make("session") const sessionID = SessionID.make("session")
const providerID = ProviderID.make("test") const providerID = ProviderID.make("test")

View File

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

View File

@ -81,7 +81,7 @@ describe("tool.edit", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const { Bus } = await import("../../src/bus") const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file") const { File } = await import("../../src/file/service")
const { FileWatcher } = await import("../../src/file/watcher") const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = [] const events: string[] = []
@ -301,7 +301,7 @@ describe("tool.edit", () => {
await FileTime.read(ctx.sessionID, filepath) await FileTime.read(ctx.sessionID, filepath)
const { Bus } = await import("../../src/bus") const { Bus } = await import("../../src/bus")
const { File } = await import("../../src/file") const { File } = await import("../../src/file/service")
const { FileWatcher } = await import("../../src/file/watcher") const { FileWatcher } = await import("../../src/file/watcher")
const events: string[] = [] const events: string[] = []

View File

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

View File

@ -4,7 +4,7 @@ import { ReadTool } from "../../src/tool/read"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission" import { Permission } from "../../src/permission/service"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema" import { SessionID, MessageID } from "../../src/session/schema"
@ -65,10 +65,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const read = await ReadTool.init() 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 = { const testCtx = {
...ctx, ...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => { ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req) requests.push(req)
}, },
} }
@ -91,10 +91,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const read = await ReadTool.init() 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 = { const testCtx = {
...ctx, ...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => { ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req) requests.push(req)
}, },
} }
@ -112,10 +112,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const read = await ReadTool.init() 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 = { const testCtx = {
...ctx, ...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => { ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req) requests.push(req)
}, },
} }
@ -138,10 +138,10 @@ describe("tool.read external_directory permission", () => {
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const read = await ReadTool.init() 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 = { const testCtx = {
...ctx, ...ctx,
ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => { ask: async (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
requests.push(req) requests.push(req)
}, },
} }
@ -176,14 +176,14 @@ describe("tool.read env file permissions", () => {
let askedForEnv = false let askedForEnv = false
const ctxWithPermissions = { const ctxWithPermissions = {
...ctx, ...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) { 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") { if (rule.action === "ask" && req.permission === "read") {
askedForEnv = true askedForEnv = true
} }
if (rule.action === "deny") { if (rule.action === "deny") {
throw new PermissionNext.DeniedError({ ruleset: agent.permission }) throw new Permission.DeniedError({ ruleset: agent.permission })
} }
} }
}, },

View File

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { PermissionNext } from "../../src/permission" import type { Permission } from "../../src/permission/service"
import type { Tool } from "../../src/tool/tool" import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill" import { SkillTool } from "../../src/tool/skill"
@ -133,7 +133,7 @@ Use this skill.
directory: tmp.path, directory: tmp.path,
fn: async () => { fn: async () => {
const tool = await SkillTool.init() 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 = { const ctx: Tool.Context = {
...baseCtx, ...baseCtx,
ask: async (req) => { ask: async (req) => {

View File

@ -71,8 +71,8 @@ describe("Truncate", () => {
}) })
test("uses default MAX_LINES and MAX_BYTES", () => { test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000) expect(TruncateSvc.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024) expect(TruncateSvc.MAX_BYTES).toBe(50 * 1024)
}) })
test("large single-line file truncates with byte message", async () => { test("large single-line file truncates with byte message", async () => {
@ -81,7 +81,7 @@ describe("Truncate", () => {
expect(result.truncated).toBe(true) expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...") expect(result.content).toContain("bytes truncated...")
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(TruncateSvc.MAX_BYTES)
}) })
test("writes full output to file when truncated", async () => { test("writes full output to file when truncated", async () => {
@ -145,10 +145,10 @@ describe("Truncate", () => {
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(Truncate.DIR, { recursive: true }) yield* fs.makeDirectory(TruncateSvc.DIR, { recursive: true })
const old = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS)) const old = path.join(TruncateSvc.DIR, Identifier.create("tool", false, Date.now() - 10 * DAY_MS))
const recent = path.join(Truncate.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS)) const recent = path.join(TruncateSvc.DIR, Identifier.create("tool", false, Date.now() - 3 * DAY_MS))
yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(old, "old content")
yield* writeFileStringScoped(recent, "recent content") yield* writeFileStringScoped(recent, "recent content")