Compare commits

...

12 Commits

Author SHA1 Message Date
Dax Raad 98aa4623c7 Use Drizzle update for session usage migration 2026-05-12 19:58:14 -04:00
Dax Raad f1e66751d5 Avoid touching session update time in data migration 2026-05-12 19:56:40 -04:00
opencode-agent[bot] 937a3c10b3 chore: generate 2026-05-12 23:49:28 +00:00
Kit Langton 72ce24c200 test: migrate effect cmd ALS test (#27173) 2026-05-12 23:48:17 +00:00
Kit Langton 3301fad8cd test: migrate app runtime logger effect tests (#27176) 2026-05-12 23:47:49 +00:00
Kit Langton 9c54255aeb fix: migrate sync http api test to effect runner (#27175) 2026-05-12 23:47:14 +00:00
Kit Langton b0674b473f test: migrate session action route to effect runner (#27174) 2026-05-12 23:46:51 +00:00
Kit Langton ded4da735b test: migrate webfetch tool tests to effect runner (#27171) 2026-05-12 23:46:23 +00:00
Kit Langton 2c334d9242 Migrate schema error body tests to Effect runner (#27172) 2026-05-12 23:46:19 +00:00
Kit Langton 81dd46abec test: migrate websearch tests to effect runner (#27170) 2026-05-12 23:45:01 +00:00
opencode-agent[bot] baef5cd43b chore: generate 2026-05-12 22:11:13 +00:00
Andrew Suffield 65368f609d fix: preserve permission ordering by accepting a layered array (#23214)
Co-authored-by: Andrew Suffield <asuffield@cloudflare.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-05-12 17:09:06 -05:00
14 changed files with 623 additions and 382 deletions
+5 -1
View File
@@ -1,4 +1,5 @@
import { Config } from "@/config/config"
import { ConfigPermission } from "@/config/permission"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
@@ -117,7 +118,10 @@ export const layer = Layer.effect(
},
})
const user = Permission.fromConfig(cfg.permission ?? {})
// Convert permission layers to rulesets and merge them
// Each layer's rules come after the previous, so later configs override earlier ones
const layers = ConfigPermission.toLayers(cfg.permission)
const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
const agents: Record<string, Info> = {
build: {
+21 -4
View File
@@ -55,6 +55,16 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
// Accumulate permission layers for later merging as rulesets.
// This preserves the ordering semantics: later rules override earlier rules.
// Each layer keeps the raw shape the user wrote on disk; consumers should use
// ConfigPermission.toLayers to normalise.
if (source.permission) {
merged.permission = [
...ConfigPermission.toLayers(target.permission),
...ConfigPermission.toLayers(source.permission),
]
}
return merged
}
@@ -228,7 +238,12 @@ export const Info = Schema.Struct({
description: "Additional instruction files or patterns to include",
}),
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(ConfigPermission.Info),
permission: Schema.optional(
Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]),
).annotate({
description:
"Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.",
}),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
attachment: Schema.optional(ConfigAttachment.Info).annotate({
description: "Attachment processing configuration, including image size limits and resizing behavior",
@@ -704,11 +719,12 @@ export const layer = Layer.effect(
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info
result.permission = [...ConfigPermission.toLayers(result.permission), envPermission]
}
if (result.tools) {
const perms: Record<string, ConfigPermission.Action> = {}
const perms: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch") {
@@ -717,7 +733,8 @@ export const layer = Layer.effect(
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
// Tools permissions come before other permissions (they can be overridden)
result.permission = [perms, ...ConfigPermission.toLayers(result.permission)]
}
if (!result.username) result.username = os.userInfo().username
@@ -56,3 +56,11 @@ export const Info = InputSchema.pipe(
).annotate({ identifier: "PermissionConfig" })
type _Info = Schema.Schema.Type<typeof InputObject>
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
// Top-level config accepts either a single permission object or an array of
// layered configs. Internal merging produces arrays; this helper normalises
// either shape into the array form expected by consumers.
export function toLayers(value: Info | Info[] | undefined): Info[] {
if (!value) return []
return Array.isArray(value) ? value : [value]
}
@@ -1,8 +1,13 @@
import { afterEach, expect, test } from "bun:test"
import { afterEach, expect } from "bun:test"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
import fs from "fs/promises"
import { fileURLToPath } from "url"
import { InstanceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(AppFileSystem.defaultLayer)
afterEach(async () => {
await disposeAllInstances()
@@ -14,35 +19,40 @@ afterEach(async () => {
// has lost the outer InstanceRef. Services that read `InstanceState.context`
// then fall back to `Instance.current` ALS, which must be installed at the JS
// callback boundary (Node ALS persists across awaits, Effect's fiber context
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
// does not). `it.instance` provides the loaded InstanceRef; the explicit
// Instance.restore mirrors effectCmd's load + ALS-restore wrap.
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
// Instance.restore boundary via the shared `it.instance` fixture,
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
// fail it. This grep guards the actual production callsite.
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
expect(source).toContain("Instance.restore(ctx")
})
it.live("effect-cmd.ts wraps the handler body in Instance.restore", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
expect(source).toContain("Instance.restore(ctx")
}),
)
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
await using dir = await tmpdir({ git: true })
await provideTestInstance({
directory: dir.path,
fn: () =>
Effect.runPromise(
Effect.promise(async () => {
await new Promise((r) => setTimeout(r, 5))
const current = await Effect.runPromise(
Effect.sync(() => {
try {
return Instance.current
} catch {
return undefined
}
}),
)
expect(current?.directory).toBe(dir.path)
it.instance(
"Instance.current reachable after await inside restored Effect.promise(async)",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const ctx = yield* InstanceRef
if (!ctx) throw new Error("InstanceRef not provided")
const current = yield* Effect.promise(() =>
Instance.restore(ctx, async () => {
await Promise.resolve()
try {
return Instance.current
} catch {
return undefined
}
}),
),
})
})
)
expect(current?.directory).toBe(test.directory)
}),
{ git: true },
)
+171 -7
View File
@@ -3,7 +3,9 @@ import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "@/config/config"
import { ConfigManaged } from "@/config/managed"
import { ConfigPermission } from "@/config/permission"
import { ConfigParse } from "../../src/config/parse"
import { Permission } from "../../src/permission"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Instance } from "../../src/project/instance"
@@ -276,6 +278,40 @@ test("updates global config and omits empty shell key in json", async () => {
}
})
test("global config update preserves single-object permission shape on disk", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
shell: "bash",
permission: { bash: "ask" },
}),
)
},
})
const prev = Global.Path.config
;(Global.Path as { config: string }).config = tmp.path
await clear(true)
try {
// Updating an unrelated key must not rewrite `permission` from object to array form.
await saveGlobal({ shell: "zsh" })
const written = await Filesystem.readJson<{ permission?: unknown; shell?: string }>(
path.join(tmp.path, "opencode.json"),
)
expect(written.shell).toBe("zsh")
expect(Array.isArray(written.permission)).toBe(false)
expect(written.permission).toEqual({ bash: "ask" })
} finally {
;(Global.Path as { config: string }).config = prev
await clear(true)
}
})
test("updates global config and omits empty shell key in jsonc", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -1713,7 +1749,10 @@ test("permission config preserves user key order", async () => {
directory: tmp.path,
fn: async () => {
const config = await load()
expect(Object.keys(config.permission!)).toEqual([
// load() goes through the merge pipeline, producing the layered array form
expect(config.permission).toHaveLength(1)
const perm = (config.permission as ConfigPermission.Info[])[0]
expect(Object.keys(perm)).toEqual([
"*",
"edit",
"write",
@@ -1729,6 +1768,129 @@ test("permission config preserves user key order", async () => {
})
})
// Global bash "rm *" deny is inherited, but user's top-level "*" ask comes after and overrides it
test("user top-level catchall overrides inherited bash rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "rm *": "deny" },
},
}),
)
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
"*": "ask",
bash: { "ls *": "allow" },
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const layers = ConfigPermission.toLayers(config.permission)
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
},
})
})
// No top-level catchall, so global bash "rm *" deny is preserved
test("inherited bash rules apply when no user top-level catchall", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "rm *": "deny" },
},
}),
)
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "ls *": "allow" },
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const layers = ConfigPermission.toLayers(config.permission)
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny")
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
},
})
})
// User's bash "*" catchall overrides global "rm *" deny
test("user bash catchall overrides inherited bash rules", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "rm *": "deny" },
},
}),
)
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Filesystem.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
bash: { "*": "ask", "ls *": "allow" },
},
}),
)
},
})
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const layers = ConfigPermission.toLayers(config.permission)
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
// Non-bash permissions should use the top-level "*" rule
expect(Permission.evaluate("read", "foo.txt", ruleset).action).toBe("ask")
},
})
})
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
const config = ConfigParse.schema(
Config.Info,
@@ -1742,7 +1904,8 @@ test("config parser preserves permission order while rejecting unknown top-level
"test",
)
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
// ConfigParse.schema preserves the raw shape the user wrote
expect(Object.keys(config.permission as ConfigPermission.Info)).toEqual(["bash", "*", "edit"])
try {
ConfigParse.schema(Config.Info, { invalid_field: true }, "test")
throw new Error("expected config parse to fail")
@@ -2579,11 +2742,12 @@ test("parseManagedPlist parses permission rules", async () => {
),
"test:mobileconfig",
)
expect(config.permission?.["*"]).toBe("ask")
expect(config.permission?.grep).toBe("allow")
expect(config.permission?.webfetch).toBe("ask")
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
const bash = config.permission?.bash as Record<string, string>
const perm = config.permission as ConfigPermission.Info
expect(perm?.["*"]).toBe("ask")
expect(perm?.grep).toBe("allow")
expect(perm?.webfetch).toBe("ask")
expect(perm?.["~/.ssh/*"]).toBe("deny")
const bash = perm?.bash as Record<string, string>
expect(bash?.["rm -rf *"]).toBe("deny")
expect(bash?.["curl *"]).toBe("deny")
})
@@ -1,12 +1,13 @@
import { expect } from "bun:test"
import { Context, Effect, Layer, Logger } from "effect"
import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AppRuntime } from "../../src/effect/app-runtime"
import { AppLayer } from "../../src/effect/app-runtime"
import { EffectBridge } from "@/effect/bridge"
import { InstanceRef } from "../../src/effect/instance-ref"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { makeRuntime } from "../../src/effect/run-service"
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
import * as Observability from "@opencode-ai/core/effect/observability"
import { attach } from "../../src/effect/run-service"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(CrossSpawnSpawner.defaultLayer)
@@ -35,17 +36,8 @@ it.live("makeRuntime installs EffectLogger through Observability.layer", () =>
}),
)
const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current()))
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
}),
)
it.live("AppRuntime also installs EffectLogger through Observability.layer", () =>
Effect.gen(function* () {
const current = yield* Effect.promise(() =>
AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)),
const current = yield* Dummy.use((svc) => svc.current()).pipe(
Effect.provide(Layer.provideMerge(layer, Observability.layer)),
)
expect(current.effectLogger).toBe(true)
@@ -53,46 +45,61 @@ it.live("AppRuntime also installs EffectLogger through Observability.layer", ()
}),
)
it.live("AppRuntime attaches InstanceRef from ALS", () =>
it.live("AppLayer also installs EffectLogger through Observability.layer", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const current = yield* Effect.promise(() =>
AppRuntime.runPromise(
const current = yield* Effect.map(Effect.service(Logger.CurrentLoggers), check).pipe(Effect.provide(AppLayer))
expect(current.effectLogger).toBe(true)
expect(current.defaultLogger).toBe(false)
}),
)
it.instance(
"attach preserves InstanceRef from the current fiber context",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const current = yield* attach(
Effect.gen(function* () {
return (yield* InstanceRef)?.directory
}),
),
).pipe(provideInstance(dir))
)
expect(current).toBe(dir)
}),
expect(current).toBe(test.directory)
}),
{ git: true },
)
it.live("EffectBridge preserves logger and instance context across async boundaries", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const result = yield* Effect.promise(() =>
AppRuntime.runPromise(
Effect.gen(function* () {
const bridge = yield* EffectBridge.make()
return yield* Effect.promise(() =>
Promise.resolve().then(() =>
bridge.promise(
Effect.gen(function* () {
return {
directory: (yield* InstanceRef)?.directory,
...check(yield* Effect.service(Logger.CurrentLoggers)),
}
}),
),
it.instance(
"EffectBridge preserves logger and instance context across async boundaries",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const bridge = yield* EffectBridge.make()
const started = yield* Deferred.make<void>()
const fiber = yield* Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
return yield* Effect.promise(() =>
Promise.resolve().then(() =>
bridge.promise(
Effect.gen(function* () {
return {
directory: (yield* InstanceRef)?.directory,
...check(yield* Effect.service(Logger.CurrentLoggers)),
}
}),
),
)
}),
),
).pipe(provideInstance(dir))
),
)
}).pipe(Effect.forkScoped)
expect(result.directory).toBe(dir)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
}),
yield* Deferred.await(started)
const result = yield* Fiber.join(fiber)
expect(result.directory).toBe(test.directory)
expect(result.effectLogger).toBe(true)
expect(result.defaultLogger).toBe(false)
}).pipe(Effect.provide(Observability.layer)),
{ git: true },
)
+19 -6
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, test, expect } from "bun:test"
import { Permission } from "../src/permission"
import { Config } from "@/config/config"
import { ConfigPermission } from "@/config/permission"
import { Instance } from "../src/project/instance"
import { WithInstance } from "../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "./fixture/fixture"
@@ -163,7 +164,9 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
// general and orchestrator-fast should be allowed, code-reviewer denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
@@ -188,7 +191,9 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
// general and code-reviewer should be ask, orchestrator-* denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
@@ -213,7 +218,9 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
// Unspecified agents default to "ask"
@@ -240,7 +247,9 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
// Verify task permissions
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
@@ -278,7 +287,9 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
// Last matching rule wins - "*" deny is last, so all agents are denied
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
@@ -309,7 +320,9 @@ describe("permission.task with real config files", () => {
directory: tmp.path,
fn: async () => {
const config = await load()
const ruleset = Permission.fromConfig(config.permission ?? {})
const ruleset = Permission.merge(
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
)
// Evaluate uses findLast - "general" allow comes after "*" deny
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
@@ -3,7 +3,6 @@ import { Effect } from "effect"
import { eq } from "drizzle-orm"
import * as Database from "@/storage/db"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session } from "@/session/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
@@ -11,80 +10,68 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { MessageID, PartID } from "../../src/session/schema"
import { PartTable } from "@/session/session.sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Session.defaultLayer)
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const withTmp = <A, E, R>(
options: Parameters<typeof tmpdir>[0],
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(fn))
async function seedCorruptStepFinishPart(directory: string) {
return WithInstance.provide({
directory,
fn: () =>
Effect.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
const seedCorruptStepFinishPart = Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
// that broke the user's session in the OMO/Windows bug.
yield* Effect.sync(() =>
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Schema.Finite still rejects NaN at encode — exact mirror of the
// corrupt row that broke the user's session in the OMO/Windows bug.
Database.use((db) =>
db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run(),
)
return info.id
}).pipe(Effect.provide(Session.defaultLayer)),
),
})
}
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run(),
),
)
return info.id
})
describe("schema-rejection wire shape", () => {
it.live(
it.instance(
"Payload schema rejection returns NamedError-shaped JSON, not empty",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
}),
)
@@ -99,36 +86,38 @@ describe("schema-rejection wire shape", () => {
expect(parsed.data.message).toEqual(expect.any(String))
expect(parsed.data.message.length).toBeGreaterThan(0)
}),
),
{ git: true, config: { formatter: false, lsp: false } },
)
it.live(
it.instance(
"Query schema rejection returns NamedError-shaped JSON",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
() =>
Effect.gen(function* () {
const test = yield* TestInstance
// /find/file?limit=999999 violates the limit constraint check.
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}`
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400)
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
}),
),
{ git: true, config: { formatter: false, lsp: false } },
)
it.live(
it.instance(
"rejected request body never echoes back unbounded — message is capped",
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter
// dumps the rejected `actual` verbatim. A multi-MB invalid array would
// become a multi-MB 400 response and log line. Cap kicks in around 1KB.
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const huge = "X".repeat(50_000)
const res = yield* Effect.promise(async () =>
Server.Default().app.request(SyncPaths.history, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: huge }),
}),
)
@@ -139,15 +128,16 @@ describe("schema-rejection wire shape", () => {
const parsed = JSON.parse(body)
expect(parsed.data.message).not.toContain(huge)
}),
),
{ git: true, config: { formatter: false, lsp: false } },
)
it.live(
it.instance(
"response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
() =>
Effect.gen(function* () {
const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path))
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
const test = yield* TestInstance
const sessionID = yield* seedCorruptStepFinishPart
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
const body = yield* Effect.promise(async () => res.text())
expect(res.status).toBe(400)
@@ -157,6 +147,6 @@ describe("schema-rejection wire shape", () => {
// Field path in data.message — what made this PR worth shipping.
expect(parsed.data.message).toMatch(/output/)
}),
),
{ config: { formatter: false, lsp: false } },
)
})
+130 -107
View File
@@ -1,29 +1,25 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn } from "bun:test"
import { Context, Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const context = Context.empty() as Context.Context<unknown>
const it = testEffect(Session.defaultLayer)
function app() {
return Server.Default().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
}
afterEach(async () => {
mock.restore()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
@@ -32,111 +28,138 @@ afterEach(async () => {
})
describe("sync HttpApi", () => {
test("serves sync routes", async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const info = spyOn(Log.create({ service: "server.sync" }), "info")
it.instance(
"serves sync routes",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const tmp = yield* TestInstance
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
const info = spyOn(Log.create({ service: "server.sync" }), "info")
const session = yield* Session.Service.use((svc) => svc.create({ title: "sync" }))
const session = await WithInstance.provide({
directory: tmp.path,
fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))),
})
const started = yield* Effect.promise(() =>
Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })),
)
expect(started.status).toBe(200)
expect(yield* Effect.promise(() => started.json())).toBe(true)
const started = await app().request(SyncPaths.start, { method: "POST", headers })
expect(started.status).toBe(200)
expect(await started.json()).toBe(true)
const history = yield* Effect.promise(() =>
Promise.resolve(
app().request(SyncPaths.history, {
method: "POST",
headers,
body: JSON.stringify({}),
}),
),
)
expect(history.status).toBe(200)
const rows = (yield* Effect.promise(() => history.json())) as Array<{
id: string
aggregate_id: string
seq: number
type: string
data: Record<string, unknown>
}>
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
const history = await app().request(SyncPaths.history, {
method: "POST",
headers,
body: JSON.stringify({}),
})
expect(history.status).toBe(200)
const rows = (await history.json()) as Array<{
id: string
aggregate_id: string
seq: number
type: string
data: Record<string, unknown>
}>
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
const replayed = await app().request(SyncPaths.replay, {
method: "POST",
headers,
body: JSON.stringify({
directory: tmp.path,
events: rows
.filter((row) => row.aggregate_id === session.id)
.map((row) => ({
id: row.id,
aggregateID: row.aggregate_id,
seq: row.seq,
type: row.type,
data: row.data,
})),
const replayed = yield* Effect.promise(() =>
Promise.resolve(
app().request(SyncPaths.replay, {
method: "POST",
headers,
body: JSON.stringify({
directory: tmp.directory,
events: rows
.filter((row) => row.aggregate_id === session.id)
.map((row) => ({
id: row.id,
aggregateID: row.aggregate_id,
seq: row.seq,
type: row.type,
data: row.data,
})),
}),
}),
),
)
expect(replayed.status).toBe(200)
expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id })
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
}),
})
expect(replayed.status).toBe(200)
expect(await replayed.json()).toEqual({ sessionID: session.id })
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
})
{ git: true, config: { formatter: false, lsp: false } },
)
test("validates seq values", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
const cases = [
{
path: SyncPaths.history,
body: { aggregate: -1 },
},
{
path: SyncPaths.history,
body: { aggregate: 1.5 },
},
{
path: SyncPaths.replay,
body: {
directory: tmp.path,
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.path,
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
]
it.instance(
"validates seq values",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
const cases = [
{
path: SyncPaths.history,
body: { aggregate: -1 },
},
{
path: SyncPaths.history,
body: { aggregate: 1.5 },
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
]
for (const item of cases) {
const response = await app().request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
})
expect(response.status).toBe(400)
}
})
test.todo("returns structured validation errors", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await ExperimentalHttpApiServer.webHandler().handler(
new Request(`http://localhost${SyncPaths.history}`, {
method: "POST",
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
for (const item of cases) {
const response = yield* Effect.promise(() =>
Promise.resolve(
app().request(item.path, {
method: "POST",
headers,
body: JSON.stringify(item.body),
}),
),
)
expect(response.status).toBe(400)
}
}),
context,
)
{ git: true, config: { formatter: false, lsp: false } },
)
expect(response.status).toBe(400)
expect(response.headers.get("content-type") ?? "").toContain("application/json")
const body = (await response.json()) as Record<string, unknown>
expect(body.success).toBe(false)
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
})
it.instance.skip(
"returns structured validation errors",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const response = yield* Effect.promise(() =>
ExperimentalHttpApiServer.webHandler().handler(
new Request(`http://localhost${SyncPaths.history}`, {
method: "POST",
headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
}),
context,
),
)
expect(response.status).toBe(400)
expect(response.headers.get("content-type") ?? "").toContain("application/json")
const body = (yield* Effect.promise(() => response.json())) as Record<string, unknown>
expect(body.success).toBe(false)
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})
@@ -1,28 +1,14 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { afterEach, describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { Server } from "../../src/server/server"
import { Session as SessionNs } from "@/session/session"
import type { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
}
const svc = {
...SessionNs,
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
remove(id: SessionID) {
return run(SessionNs.Service.use((svc) => svc.remove(id)))
},
}
const it = testEffect(SessionNs.defaultLayer)
afterEach(async () => {
mock.restore()
@@ -30,21 +16,28 @@ afterEach(async () => {
})
describe("session action routes", () => {
test("abort route returns success", async () => {
await using tmp = await tmpdir({ git: true })
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const session = await svc.create({})
const app = Server.Default().app
it.instance(
"abort route returns success",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const session = yield* Effect.acquireRelease(
SessionNs.Service.use((svc) => svc.create({})),
(created) => SessionNs.Service.use((svc) => svc.remove(created.id)).pipe(Effect.ignore),
)
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
const res = yield* Effect.promise(() =>
Promise.resolve(
Server.Default().app.request(`/session/${session.id}/abort`, {
method: "POST",
headers: { "x-opencode-directory": test.directory },
}),
),
)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
await svc.remove(session.id)
},
})
})
expect(yield* Effect.promise(() => res.json())).toBe(true)
}),
{ git: true },
)
})
+51 -61
View File
@@ -1,15 +1,14 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { Agent } from "../../src/agent/agent"
import { Truncate } from "@/tool/truncate"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { WebFetchTool } from "../../src/tool/webfetch"
import { SessionID, MessageID } from "../../src/session/schema"
import { Tool } from "@/tool/tool"
import { testEffect } from "../lib/effect"
const projectRoot = path.join(import.meta.dir, "../..")
const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer))
const ctx = {
sessionID: SessionID.make("ses_test"),
@@ -22,30 +21,31 @@ const ctx = {
ask: () => Effect.void,
}
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
using server = Bun.serve({ port: 0, fetch })
await fn(server.url)
}
function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
return WebFetchTool.pipe(
Effect.flatMap((info) => info.init()),
Effect.flatMap((tool) => tool.execute(args, ctx)),
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
Effect.runPromise,
const withFetch = <A, E, R>(
fetch: (req: Request) => Response | Promise<Response>,
fn: (url: URL) => Effect.Effect<A, E, R>,
) =>
Effect.acquireUseRelease(
Effect.sync(() => Bun.serve({ port: 0, fetch })),
(server) => fn(server.url),
(server) => Effect.sync(() => server.stop(true)),
)
}
const exec = Effect.fn("WebFetchToolTest.exec")(function* (args: Tool.InferParameters<typeof WebFetchTool>) {
const info = yield* WebFetchTool
const tool = yield* info.init()
return yield* tool.execute(args, ctx)
})
describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
await withFetch(
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async (url) => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
it.instance("returns image responses as file attachments", () =>
Effect.gen(function* () {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
yield* withFetch(
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
@@ -55,50 +55,40 @@ describe("tool.webfetch", () => {
expect(result.attachments?.[0]).not.toHaveProperty("id")
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
},
})
},
)
})
}),
)
}),
)
test("keeps svg as text output", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
await withFetch(
it.instance("keeps svg as text output", () =>
withFetch(
() =>
new Response(svg, {
new Response('<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>', {
status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}),
async (url) => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
},
})
},
)
})
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/image.svg", url).toString(), format: "html" })
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
}),
),
)
test("keeps text responses as text output", async () => {
await withFetch(
it.instance("keeps text responses as text output", () =>
withFetch(
() =>
new Response("hello from webfetch", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
}),
async (url) => {
await WithInstance.provide({
directory: projectRoot,
fn: async () => {
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
},
})
},
)
})
(url) =>
Effect.gen(function* () {
const result = yield* exec({ url: new URL("/file.txt", url).toString(), format: "text" })
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
}),
),
)
})
+19 -11
View File
@@ -4,6 +4,7 @@ import { parseResponse } from "../../src/tool/mcp-websearch"
import { selectWebSearchProvider, webSearchModelName, webSearchProviderLabel } from "../../src/tool/websearch"
import { ProviderID } from "../../src/provider/schema"
import { webSearchEnabled } from "../../src/tool/registry"
import { it } from "../lib/effect"
const SESSION_ID = "ses_0196aabbccddeeff001122334455"
@@ -74,17 +75,24 @@ describe("websearch MCP response parser", () => {
},
})
test("parses plain JSON-RPC responses", async () => {
await expect(Effect.runPromise(parseResponse(payload))).resolves.toBe("search results")
})
it.effect("parses plain JSON-RPC responses", () =>
Effect.gen(function* () {
const result = yield* parseResponse(payload)
expect(result).toBe("search results")
}),
)
test("parses SSE JSON-RPC responses", async () => {
await expect(Effect.runPromise(parseResponse(`event: message\ndata: ${payload}\n\n`))).resolves.toBe(
"search results",
)
})
it.effect("parses SSE JSON-RPC responses", () =>
Effect.gen(function* () {
const result = yield* parseResponse(`event: message\ndata: ${payload}\n\n`)
expect(result).toBe("search results")
}),
)
test("ignores non-JSON SSE data frames", async () => {
await expect(Effect.runPromise(parseResponse(`data: [DONE]\ndata: ${payload}\n\n`))).resolves.toBe("search results")
})
it.effect("ignores non-JSON SSE data frames", () =>
Effect.gen(function* () {
const result = yield* parseResponse(`data: [DONE]\ndata: ${payload}\n\n`)
expect(result).toBe("search results")
}),
)
})
+4 -1
View File
@@ -1263,7 +1263,10 @@ export type Config = {
}
instructions?: Array<string>
layout?: LayoutConfig
permission?: PermissionConfig
/**
* Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.
*/
permission?: PermissionConfig | Array<PermissionConfig>
tools?: {
[key: string]: boolean
}
+12 -1
View File
@@ -12407,7 +12407,18 @@
"$ref": "#/components/schemas/LayoutConfig"
},
"permission": {
"$ref": "#/components/schemas/PermissionConfig"
"anyOf": [
{
"$ref": "#/components/schemas/PermissionConfig"
},
{
"type": "array",
"items": {
"$ref": "#/components/schemas/PermissionConfig"
}
}
],
"description": "Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones."
},
"tools": {
"type": "object",