feat(core): reload config on filesystem changes
This commit is contained in:
parent
b04d8d53e6
commit
c9b24ef027
48 changed files with 596 additions and 510 deletions
|
|
@ -8,7 +8,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
|||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
|
|
@ -24,7 +23,7 @@ const locationLayer = Layer.succeed(
|
|||
Location.Service.of(location({ directory: AbsolutePath.make("test") })),
|
||||
)
|
||||
const catalogLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node, Policy.node]),
|
||||
LayerNode.group([Catalog.node, EventV2.node, Credential.node, Integration.node]),
|
||||
[[Location.node, locationLayer]],
|
||||
)
|
||||
const it = testEffect(catalogLayer)
|
||||
|
|
@ -333,21 +332,4 @@ describe("CatalogV2", () => {
|
|||
expect((yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("removes providers denied by policy after loading", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const policy = yield* Policy.Service
|
||||
const providerID = ProviderV2.ID.make("blocked")
|
||||
yield* policy.load([new Policy.Info({ effect: "deny", action: "provider.use", resource: "blocked" })])
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(providerID, () => {})
|
||||
catalog.model.update(providerID, ModelV2.ID.make("model"), () => {})
|
||||
})
|
||||
|
||||
expect(yield* catalog.provider.all()).toEqual([])
|
||||
expect(yield* catalog.model.all()).toEqual([])
|
||||
expect(yield* catalog.provider.get(providerID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, PubSub, Schema, Stream } from "effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
|
|
@ -19,7 +21,7 @@ import { testEffect } from "../lib/effect"
|
|||
import { host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [
|
||||
AppNodeBuilder.build(LayerNode.group([CommandV2.node, EventV2.node, FSUtil.node]), [
|
||||
[MCP.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
|
|
@ -53,6 +55,9 @@ Review files`,
|
|||
})
|
||||
|
||||
const command = yield* CommandV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const update = yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
const updates = yield* PubSub.unbounded<typeof update>()
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
|
|
@ -60,6 +65,7 @@ Review files`,
|
|||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
event: { subscribe: () => Stream.fromPubSub(updates) },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(
|
||||
|
|
@ -93,6 +99,15 @@ Review files`,
|
|||
CommandV2.Info.make({ name: "empty", template: "" }),
|
||||
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
|
||||
])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
|
||||
yield* Effect.sleep("10 millis")
|
||||
yield* PubSub.publish(updates, update)
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if ((yield* command.get("review"))?.template === "Review again") break
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
expect((yield* command.get("review"))?.template).toBe("Review again")
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
|
|
@ -26,6 +28,7 @@ function testLayer(
|
|||
globalDirectory = path.join(directory, "global"),
|
||||
projectDirectory = directory,
|
||||
vcs?: Project.Vcs,
|
||||
watcher?: Layer.Layer<Watcher.Service>,
|
||||
) {
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
|
|
@ -36,9 +39,10 @@ function testLayer(
|
|||
),
|
||||
),
|
||||
)
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, EventV2.node]), [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: globalDirectory })],
|
||||
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
|
||||
])
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +56,52 @@ const provider = {
|
|||
}
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
const file = path.join(global, "opencode.json")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
await fs.writeFile(file, JSON.stringify({ shell: "first" }))
|
||||
})
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: () => Stream.fromPubSub(updates),
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const events = yield* EventV2.Service
|
||||
const changed = yield* events
|
||||
.subscribe(ConfigSchema.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* PubSub.publish(updates, {
|
||||
type: "update",
|
||||
path: path.join(global, "commands", "review.md"),
|
||||
} satisfies Watcher.Update)
|
||||
yield* Effect.promise(() => fs.writeFile(file, JSON.stringify({ shell: "second" })))
|
||||
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
|
||||
|
||||
expect(yield* Fiber.join(changed)).toHaveLength(1)
|
||||
expect(Config.latest(yield* config.entries(), "shell")).toBe("second")
|
||||
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
|
|
@ -274,7 +324,6 @@ describe("Config", () => {
|
|||
const file = path.join(tmp.path, "opencode.json")
|
||||
const contents = JSON.stringify({
|
||||
shell: "/bin/zsh",
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
|
||||
providers: { local: provider },
|
||||
})
|
||||
yield* Effect.promise(() => fs.writeFile(file, contents))
|
||||
|
|
@ -285,11 +334,6 @@ describe("Config", () => {
|
|||
|
||||
expect(documents[0]?.info.$schema).toBeUndefined()
|
||||
expect(documents[0]?.info.shell).toBe("/bin/zsh")
|
||||
expect(documents[0]?.info.experimental?.policies?.[0]).toEqual({
|
||||
effect: "deny",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe(contents)
|
||||
}).pipe(Effect.provide(testLayer(tmp.path)))
|
||||
}),
|
||||
|
|
@ -723,40 +767,6 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("loads policy statements in reverse config order", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(global, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(global, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "openai" }] },
|
||||
}),
|
||||
)
|
||||
await fs.writeFile(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "allow", action: "provider.use", resource: "openai" }] },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}).pipe(Effect.provide(testLayer(tmp.path, global)))
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads global, ancestor, and .opencode configuration up to the project boundary", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { LocationWatcher } from "@opencode-ai/core/filesystem/location-watcher"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
|
|
@ -34,7 +36,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
|||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(Watcher.node, [
|
||||
AppNodeBuilder.build(LocationWatcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
|
|
@ -66,7 +68,7 @@ function wait(check: (event: WatcherEvent) => boolean) {
|
|||
return Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const deferred = yield* Deferred.make<WatcherEvent>()
|
||||
const fiber = yield* events.subscribe(Watcher.Event.Updated).pipe(
|
||||
const fiber = yield* events.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (!check(event.data)) return Effect.void
|
||||
return Deferred.succeed(deferred, event.data).pipe(Effect.asVoid)
|
||||
|
|
@ -136,7 +138,27 @@ function ready(directory: string) {
|
|||
})
|
||||
}
|
||||
|
||||
describeWatcher("Watcher", () => {
|
||||
describeWatcher("LocationWatcher", () => {
|
||||
it.live("limits file watches to the exact target", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const target = path.join(directory, "opencode.json")
|
||||
const sibling = path.join(directory, "other.json")
|
||||
const update = yield* watcher
|
||||
.subscribe({ path: target, type: "file" })
|
||||
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* fs.writeFileString(sibling, "sibling")
|
||||
yield* fs.writeFileString(target, "target")
|
||||
|
||||
expect((yield* Fiber.join(update)).valueOrUndefined?.path).toBe(target)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Watcher.node))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("publishes root create, update, and delete events", () =>
|
||||
withTmp(
|
||||
(directory) =>
|
||||
|
|
|
|||
|
|
@ -51,27 +51,18 @@ describe("LocationServiceMap", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("isolates location state while sharing location policy with catalog", () =>
|
||||
it.live("isolates catalog state by location", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
).pipe(
|
||||
Effect.flatMap(([blocked, allowed]) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(
|
||||
path.join(blocked.path, "opencode.json"),
|
||||
JSON.stringify({
|
||||
experimental: { policies: [{ effect: "deny", action: "provider.use", resource: "test" }] },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const update = (directory: string) =>
|
||||
const update = (directory: string, providerID: ProviderV2.ID) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Reference.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {}))
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
const registry = yield* ToolRegistry.Service
|
||||
// Tool plugins register during the forked PluginInternal boot; wait for
|
||||
// every expected tool rather than relying on batch ordering.
|
||||
|
|
@ -103,8 +94,11 @@ describe("LocationServiceMap", () => {
|
|||
),
|
||||
)
|
||||
|
||||
const blockedState = yield* update(blocked.path)
|
||||
expect(blockedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(false)
|
||||
const blockedID = ProviderV2.ID.make("blocked-location")
|
||||
const allowedID = ProviderV2.ID.make("allowed-location")
|
||||
const blockedState = yield* update(blocked.path, blockedID)
|
||||
expect(blockedState.providers.some((provider) => provider.id === blockedID)).toBe(true)
|
||||
expect(blockedState.providers.some((provider) => provider.id === allowedID)).toBe(false)
|
||||
expect(blockedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
|
|
@ -119,8 +113,9 @@ describe("LocationServiceMap", () => {
|
|||
"websearch",
|
||||
"write",
|
||||
])
|
||||
const allowedState = yield* update(allowed.path)
|
||||
expect(allowedState.providers.some((provider) => provider.id === ProviderV2.ID.make("test"))).toBe(true)
|
||||
const allowedState = yield* update(allowed.path, allowedID)
|
||||
expect(allowedState.providers.some((provider) => provider.id === allowedID)).toBe(true)
|
||||
expect(allowedState.providers.some((provider) => provider.id === blockedID)).toBe(false)
|
||||
expect(allowedState.tools.map((tool) => tool.name).sort()).toEqual([
|
||||
"edit",
|
||||
"glob",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Fiber, Schema } from "effect"
|
||||
import { Effect, Exit, Fiber, Schema, Stream } from "effect"
|
||||
import { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
|
|
@ -14,6 +17,24 @@ import { PluginTestLayer } from "./plugin/fixture"
|
|||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
describe("PluginV2", () => {
|
||||
it.live("exposes public events through the plugin context", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
const received = yield* host.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runHead,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
|
||||
expect((yield* Fiber.join(received)).valueOrUndefined?.type).toBe("config.updated")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for a plugin and returns immediately once active", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* PluginV2.Service
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Integration } from "@opencode-ai/core/integration"
|
|||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import type { IntegrationEnvMethod, IntegrationKeyMethod, IntegrationOAuthMethod } from "@opencode-ai/sdk/v2/types"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
|
||||
type Overrides = Partial<Omit<PluginContext, "options">>
|
||||
|
||||
|
|
@ -39,6 +39,9 @@ export function host(overrides: Overrides = {}): PluginContext {
|
|||
transform: () => Effect.die("unused command.transform"),
|
||||
reload: () => Effect.die("unused command.reload"),
|
||||
},
|
||||
event: overrides.event ?? {
|
||||
subscribe: () => Stream.empty,
|
||||
},
|
||||
integration: overrides.integration ?? {
|
||||
list: () => Effect.die("unused integration.list"),
|
||||
get: () => Effect.die("unused integration.get"),
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Policy.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
],
|
||||
]),
|
||||
)
|
||||
|
||||
describe("Policy", () => {
|
||||
it.effect("returns the caller's fallback when no statement matches", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "deny")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("evaluates wildcard provider rules in written order", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.*",
|
||||
resource: "*",
|
||||
}),
|
||||
new Policy.Info({
|
||||
effect: "allow",
|
||||
action: "provider.use",
|
||||
resource: "anthropic",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "anthropic", "allow")).toBe("allow")
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("matches action and resource independently", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.*",
|
||||
resource: "company-*",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "company-stable", "allow")).toBe("deny")
|
||||
expect(yield* policy.evaluate("plugin.load", "company-stable", "allow")).toBe("allow")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the last matching loaded statement", () =>
|
||||
Effect.gen(function* () {
|
||||
const policy = yield* Policy.Service
|
||||
yield* policy.load([
|
||||
new Policy.Info({
|
||||
effect: "allow",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
}),
|
||||
new Policy.Info({
|
||||
effect: "deny",
|
||||
action: "provider.use",
|
||||
resource: "openai",
|
||||
}),
|
||||
])
|
||||
|
||||
expect(yield* policy.evaluate("provider.use", "openai", "allow")).toBe("deny")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -10,7 +10,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util"
|
|||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { FileSystemWatcher } from "@opencode-ai/schema/filesystem-watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
|
|
@ -206,7 +206,7 @@ metadata:
|
|||
waitForSkillUpdate(),
|
||||
({ deferred }) =>
|
||||
events
|
||||
.publish(FileSystemWatcher.Event.Updated, { file, event: "change" })
|
||||
.publish(FileSystem.Event.Changed, { file, event: "change" })
|
||||
.pipe(Effect.andThen(Deferred.await(deferred)), Effect.timeout("1 second")),
|
||||
({ fiber }) => Fiber.interrupt(fiber),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue