mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-27 19:04:43 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98aa4623c7 | |||
| f1e66751d5 | |||
| 937a3c10b3 | |||
| 72ce24c200 | |||
| 3301fad8cd | |||
| 9c54255aeb | |||
| b0674b473f | |||
| ded4da735b | |||
| 2c334d9242 | |||
| 81dd46abec | |||
| baef5cd43b | |||
| 65368f609d |
@@ -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: {
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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 } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user