From cb32c42e6f842bcad7e30d4c74c308012fdb65ab Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 00:25:31 -0400 Subject: [PATCH 1/5] refactor(core): resolve database and websearch config through Effect Config --- packages/core/src/database/database.ts | 39 ++++++++++----- packages/core/src/flag/flag.ts | 1 - packages/core/src/tool/websearch.ts | 41 ++++++++++------ packages/core/test/database-path.test.ts | 38 +++++++++++++++ packages/core/test/tool-websearch.test.ts | 48 ++++++++++++++++++- .../server/httpapi-exercise/environment.ts | 1 - packages/sdk-next/test/embedded.test.ts | 23 ++++----- 7 files changed, 148 insertions(+), 43 deletions(-) create mode 100644 packages/core/test/database-path.test.ts diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index d61adf047e..ab86a37e74 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -2,9 +2,9 @@ export * as Database from "./database" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { layer as sqliteLayer } from "#sqlite" -import { Context, Effect, Layer } from "effect" +import { Config, Context, Effect, Layer, Option } from "effect" import { Global } from "../global" -import { Flag } from "../flag/flag" +import { truthy } from "../flag/flag" import { isAbsolute, join } from "path" import { DatabaseMigration } from "./migration" import { InstallationChannel } from "../installation/version" @@ -40,18 +40,33 @@ export function layerFromPath(filename: string) { return layer.pipe(Layer.provide(sqliteLayer({ filename }))) } -export function path() { - if (Flag.OPENCODE_DB) { - if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB - return join(Global.Path.data, Flag.OPENCODE_DB) +/** One placement rule shared by the config-backed layer and the V1 `path()` helper. */ +export function resolvePath(input: { readonly file: string | undefined; readonly disableChannelDb: boolean }) { + if (input.file) { + if (input.file === ":memory:" || isAbsolute(input.file)) return input.file + return join(Global.Path.data, input.file) } - if ( - ["latest", "beta", "prod"].includes(InstallationChannel) || - process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || - process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" - ) + if (["latest", "beta", "prod"].includes(InstallationChannel) || input.disableChannelDb) return join(Global.Path.data, "opencode.db") return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) } -export const node = makeGlobalNode({ service: Service, layer: layerFromPath(path()), deps: [] }) +/** V1 compatibility helper; reads the process environment at call time. */ +export function path() { + return resolvePath({ + file: process.env.OPENCODE_DB, + disableChannelDb: truthy("OPENCODE_DISABLE_CHANNEL_DB"), + }) +} + +// Placement is resolved through Effect Config when the layer is built, not at +// module import, so tests and tooling can override it with a ConfigProvider. +const configuredLayer = Layer.unwrap( + Effect.gen(function* () { + const file = yield* Config.option(Config.string("OPENCODE_DB")) + const disableChannelDb = yield* Config.boolean("OPENCODE_DISABLE_CHANNEL_DB").pipe(Config.withDefault(false)) + return layerFromPath(resolvePath({ file: Option.getOrUndefined(file), disableChannelDb })) + }).pipe(Effect.orDie), +) + +export const node = makeGlobalNode({ service: Service, layer: configuredLayer, deps: [] }) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13e..47222a5e40 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -44,7 +44,6 @@ export const Flag = { copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"], OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"], - OPENCODE_DB: process.env["OPENCODE_DB"], OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"], OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 6d62236316..efd126f253 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,11 +1,10 @@ export * as WebSearchTool from "./websearch" import { ToolFailure } from "@opencode-ai/llm" -import { Context, Duration, Effect, Layer, Schema } from "effect" +import { Config, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { makeLocationNode } from "../effect/app-node" import { LayerNodePlatform } from "../effect/app-node-platform" -import { truthy } from "../flag/flag" import { InstallationVersion } from "../installation/version" import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" @@ -69,19 +68,33 @@ export interface Config { export class ConfigService extends Context.Service()("@opencode/v2/WebSearchConfig") {} -/** Isolates the retained product environment contract from the generic tool implementation. */ -export const defaultConfigLayer = Layer.sync(ConfigService, () => - ConfigService.of({ - provider: - process.env.OPENCODE_WEBSEARCH_PROVIDER === "exa" || process.env.OPENCODE_WEBSEARCH_PROVIDER === "parallel" - ? process.env.OPENCODE_WEBSEARCH_PROVIDER - : undefined, - enableExa: truthy("OPENCODE_EXPERIMENTAL") || truthy("OPENCODE_ENABLE_EXA") || truthy("OPENCODE_EXPERIMENTAL_EXA"), - enableParallel: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"), - exaApiKey: process.env.EXA_API_KEY, - parallelApiKey: process.env.PARALLEL_API_KEY, +const flag = (name: string) => Config.boolean(name).pipe(Config.withDefault(false)) + +/** + * Isolates the retained product environment contract from the generic tool + * implementation. Reads through Effect `Config` when the layer is built, so + * tests can override values with a `ConfigProvider` instead of mutating + * `process.env`. Malformed values fail the layer instead of silently + * disabling a provider the user asked for. + */ +export const defaultConfigLayer = Layer.effect( + ConfigService, + Effect.gen(function* () { + const provider = yield* Config.option(Config.literals(["exa", "parallel"], "OPENCODE_WEBSEARCH_PROVIDER")) + const exaApiKey = yield* Config.option(Config.redacted("EXA_API_KEY")) + const parallelApiKey = yield* Config.option(Config.redacted("PARALLEL_API_KEY")) + return ConfigService.of({ + provider: Option.getOrUndefined(provider), + enableExa: + (yield* flag("OPENCODE_EXPERIMENTAL")) || + (yield* flag("OPENCODE_ENABLE_EXA")) || + (yield* flag("OPENCODE_EXPERIMENTAL_EXA")), + enableParallel: (yield* flag("OPENCODE_ENABLE_PARALLEL")) || (yield* flag("OPENCODE_EXPERIMENTAL_PARALLEL")), + exaApiKey: Option.getOrUndefined(Option.map(exaApiKey, Redacted.value)), + parallelApiKey: Option.getOrUndefined(Option.map(parallelApiKey, Redacted.value)), + }) }), -) +).pipe(Layer.orDie) export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] }) diff --git a/packages/core/test/database-path.test.ts b/packages/core/test/database-path.test.ts new file mode 100644 index 0000000000..20ab38957c --- /dev/null +++ b/packages/core/test/database-path.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { ConfigProvider, Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { Global } from "@opencode-ai/core/global" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { tmpdir } from "./fixture/tmpdir" + +describe("Database placement", () => { + test("resolves explicit files, relative names, and the channel default", () => { + expect(Database.resolvePath({ file: ":memory:", disableChannelDb: false })).toBe(":memory:") + expect(Database.resolvePath({ file: "/tmp/explicit.db", disableChannelDb: false })).toBe("/tmp/explicit.db") + expect(Database.resolvePath({ file: "relative.db", disableChannelDb: false })).toBe( + path.join(Global.Path.data, "relative.db"), + ) + expect(Database.resolvePath({ file: undefined, disableChannelDb: true })).toBe( + path.join(Global.Path.data, "opencode.db"), + ) + }) + + test("reads placement from the active ConfigProvider when the layer is built", async () => { + await using tmp = await tmpdir() + const file = path.join(tmp.path, "config-seam.sqlite") + + // The preload sets OPENCODE_DB=":memory:" in the process environment, so a + // database appearing at this path proves the layer reads through the + // replaced ConfigProvider rather than the environment snapshot. + await Effect.runPromise( + Layer.build( + LayerNode.compile(LayerNode.group([Database.node])).pipe( + Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_DB: file }))), + ), + ).pipe(Effect.scoped, Effect.asVoid), + ) + + expect(await Bun.file(file).exists()).toBe(true) + }) +}) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 0b99a80ecb..55345b1a51 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { ConfigProvider, Effect, Exit, Layer, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -47,6 +47,52 @@ describe("WebSearchTool provider selection", () => { }) }) +const readDefaultConfig = (env: Record) => + Effect.gen(function* () { + return yield* WebSearchTool.ConfigService + }).pipe( + Effect.provide( + WebSearchTool.defaultConfigLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(env)))), + ), + ) + +describe("WebSearchTool default config", () => { + test("decodes an empty environment to defaults", async () => { + expect(await Effect.runPromise(readDefaultConfig({}))).toEqual({ + provider: undefined, + enableExa: false, + enableParallel: false, + exaApiKey: undefined, + parallelApiKey: undefined, + }) + }) + + test("decodes provider, truthy flags, and credentials from the active ConfigProvider", async () => { + expect( + await Effect.runPromise( + readDefaultConfig({ + OPENCODE_WEBSEARCH_PROVIDER: "parallel", + OPENCODE_ENABLE_EXA: "1", + OPENCODE_EXPERIMENTAL_PARALLEL: "true", + EXA_API_KEY: "exa-key", + PARALLEL_API_KEY: "parallel-key", + }), + ), + ).toEqual({ + provider: "parallel", + enableExa: true, + enableParallel: true, + exaApiKey: "exa-key", + parallelApiKey: "parallel-key", + }) + }) + + test("fails on an invalid provider instead of silently ignoring it", async () => { + const exit = await Effect.runPromiseExit(readDefaultConfig({ OPENCODE_WEBSEARCH_PROVIDER: "bing" })) + expect(Exit.isFailure(exit)).toBe(true) + }) +}) + describe("WebSearchTool MCP response parser", () => { test("parses plain JSON-RPC responses", async () => { expect(await Effect.runPromise(WebSearchTool.parseResponse(payload("search results")))).toBe("search results") diff --git a/packages/opencode/test/server/httpapi-exercise/environment.ts b/packages/opencode/test/server/httpapi-exercise/environment.ts index 9d3eaa0e53..985215c451 100644 --- a/packages/opencode/test/server/httpapi-exercise/environment.ts +++ b/packages/opencode/test/server/httpapi-exercise/environment.ts @@ -19,7 +19,6 @@ export const exerciseDatabasePath = process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) process.env.OPENCODE_DB = exerciseDatabasePath -Flag.OPENCODE_DB = exerciseDatabasePath export const original = { OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 5c1b8b238a..01cbec0e66 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -1,15 +1,20 @@ -import { expect, test } from "bun:test" +import { afterAll, expect, test } from "bun:test" import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { Flag } from "@opencode-ai/core/flag/flag" import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" import type { OpenCodeEvent } from "../src" +// The database layer resolves OPENCODE_DB through Effect Config, and the +// default ConfigProvider snapshots the process environment on first use, so +// database placement is process-wide. Point every embedded host in this file +// at one shared temporary database before anything builds a runtime. +const databaseDirectory = await mkdtemp(join(tmpdir(), "opencode-embedded-db-")) +process.env.OPENCODE_DB = join(databaseDirectory, "opencode.sqlite") +afterAll(() => rm(databaseDirectory, { recursive: true, force: true })) + test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) - const database = Flag.OPENCODE_DB - Flag.OPENCODE_DB = join(directory, "opencode.sqlite") const { AbsolutePath, Agent, Location, Model, OpenCode, Prompt, Provider, Session, Tool } = await import("../src") const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) const model = Model.Ref.make({ id: Model.ID.make("embedded"), providerID: Provider.ID.make("test") }) @@ -99,15 +104,12 @@ test("embedded client uses the real router and handlers", async () => { }) await Effect.runPromise(Effect.scoped(program)) } finally { - Flag.OPENCODE_DB = database await rm(directory, { recursive: true, force: true }) } }) test("Location-owned runner events reach the ready global client", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-events-")) - const database = Flag.OPENCODE_DB - Flag.OPENCODE_DB = join(directory, "opencode.sqlite") const { AbsolutePath, Location, OpenCode, Prompt, Session } = await import("../src") const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) @@ -138,15 +140,12 @@ test("Location-owned runner events reach the ready global client", async () => { }) await Effect.runPromise(Effect.scoped(program)) } finally { - Flag.OPENCODE_DB = database await rm(directory, { recursive: true, force: true }) } }, 10_000) test("independent embedded hosts do not share live notifications", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-hosts-")) - const database = Flag.OPENCODE_DB - Flag.OPENCODE_DB = join(directory, "opencode.sqlite") const { AbsolutePath, Agent, Location, OpenCode, Session } = await import("../src") const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) @@ -181,15 +180,12 @@ test("independent embedded hosts do not share live notifications", async () => { }) await Effect.runPromise(Effect.scoped(program)) } finally { - Flag.OPENCODE_DB = database await rm(directory, { recursive: true, force: true }) } }, 10_000) test("embedded client is available as a Layer service", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-layer-")) - const database = Flag.OPENCODE_DB - Flag.OPENCODE_DB = join(directory, "opencode.sqlite") const { AbsolutePath, Location, OpenCode, Session } = await import("../src") const sessionID = Session.ID.make(`ses_embedded_${crypto.randomUUID()}`) @@ -206,7 +202,6 @@ test("embedded client is available as a Layer service", async () => { expect(created.id).toBe(sessionID) } finally { - Flag.OPENCODE_DB = database await rm(directory, { recursive: true, force: true }) } }) From ca318328ea5a3a7ff9e2de921c19df00456c3ece Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 09:20:23 -0400 Subject: [PATCH 2/5] fix(core): keep lenient env grammar for config-backed layers --- packages/core/src/database/database.ts | 16 ++++---- packages/core/src/flag/flag.ts | 20 ++++++++-- packages/core/src/tool/websearch.ts | 39 +++++++++++-------- packages/core/test/tool-websearch.test.ts | 25 +++++++----- .../server/httpapi-exercise/environment.ts | 3 ++ 5 files changed, 67 insertions(+), 36 deletions(-) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index ab86a37e74..78585ce8a8 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -2,9 +2,9 @@ export * as Database from "./database" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { layer as sqliteLayer } from "#sqlite" -import { Config, Context, Effect, Layer, Option } from "effect" +import { Config, Context, Effect, Layer } from "effect" import { Global } from "../global" -import { truthy } from "../flag/flag" +import { truthy, truthyConfig } from "../flag/flag" import { isAbsolute, join } from "path" import { DatabaseMigration } from "./migration" import { InstallationChannel } from "../installation/version" @@ -61,12 +61,14 @@ export function path() { // Placement is resolved through Effect Config when the layer is built, not at // module import, so tests and tooling can override it with a ConfigProvider. +// truthyConfig shares the `truthy` grammar so this layer and the V1 `path()` +// helper always agree on the same environment. const configuredLayer = Layer.unwrap( Effect.gen(function* () { - const file = yield* Config.option(Config.string("OPENCODE_DB")) - const disableChannelDb = yield* Config.boolean("OPENCODE_DISABLE_CHANNEL_DB").pipe(Config.withDefault(false)) - return layerFromPath(resolvePath({ file: Option.getOrUndefined(file), disableChannelDb })) - }).pipe(Effect.orDie), -) + const file = yield* Config.string("OPENCODE_DB").pipe(Config.withDefault(undefined)) + const disableChannelDb = yield* truthyConfig("OPENCODE_DISABLE_CHANNEL_DB") + return layerFromPath(resolvePath({ file, disableChannelDb })) + }), +).pipe(Layer.orDie) export const node = makeGlobalNode({ service: Service, layer: configuredLayer, deps: [] }) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 47222a5e40..f7c1d9aaa4 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,10 +1,24 @@ import { Config } from "effect" -export function truthy(key: string) { - const value = process.env[key]?.toLowerCase() - return value === "true" || value === "1" +function truthyValue(value: string) { + const lower = value.toLowerCase() + return lower === "true" || lower === "1" } +export function truthy(key: string) { + const value = process.env[key] + return value !== undefined && truthyValue(value) +} + +/** + * The `truthy` grammar read through Effect Config, so layer builds can be + * steered by a ConfigProvider. Missing or malformed values are false, exactly + * like `truthy`; strict boolean decoding would turn a stray env value into a + * startup defect. + */ +export const truthyConfig = (name: string) => + Config.string(name).pipe(Config.map(truthyValue), Config.withDefault(false)) + const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] const fff = process.env["OPENCODE_DISABLE_FFF"] diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index efd126f253..661ecdcd0d 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -1,10 +1,11 @@ export * as WebSearchTool from "./websearch" import { ToolFailure } from "@opencode-ai/llm" -import { Config, Context, Duration, Effect, Layer, Option, Redacted, Schema } from "effect" +import { Config, Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { makeLocationNode } from "../effect/app-node" import { LayerNodePlatform } from "../effect/app-node-platform" +import { truthyConfig } from "../flag/flag" import { InstallationVersion } from "../installation/version" import { PositiveInt } from "../schema" import { PermissionV2 } from "../permission" @@ -68,30 +69,36 @@ export interface Config { export class ConfigService extends Context.Service()("@opencode/v2/WebSearchConfig") {} -const flag = (name: string) => Config.boolean(name).pipe(Config.withDefault(false)) - /** * Isolates the retained product environment contract from the generic tool * implementation. Reads through Effect `Config` when the layer is built, so * tests can override values with a `ConfigProvider` instead of mutating - * `process.env`. Malformed values fail the layer instead of silently - * disabling a provider the user asked for. + * `process.env`. Parsing keeps the legacy lenient grammar: missing or + * malformed values disable rather than fail, because this layer builds at + * Location boot where a defect would surface as opaque session failures. */ export const defaultConfigLayer = Layer.effect( ConfigService, Effect.gen(function* () { - const provider = yield* Config.option(Config.literals(["exa", "parallel"], "OPENCODE_WEBSEARCH_PROVIDER")) - const exaApiKey = yield* Config.option(Config.redacted("EXA_API_KEY")) - const parallelApiKey = yield* Config.option(Config.redacted("PARALLEL_API_KEY")) + const env = yield* Config.all({ + provider: Config.string("OPENCODE_WEBSEARCH_PROVIDER").pipe(Config.withDefault(undefined)), + experimental: truthyConfig("OPENCODE_EXPERIMENTAL"), + enableExa: truthyConfig("OPENCODE_ENABLE_EXA"), + experimentalExa: truthyConfig("OPENCODE_EXPERIMENTAL_EXA"), + enableParallel: truthyConfig("OPENCODE_ENABLE_PARALLEL"), + experimentalParallel: truthyConfig("OPENCODE_EXPERIMENTAL_PARALLEL"), + exaApiKey: Config.string("EXA_API_KEY").pipe(Config.withDefault(undefined)), + parallelApiKey: Config.string("PARALLEL_API_KEY").pipe(Config.withDefault(undefined)), + }) + const provider = env.provider !== undefined && Schema.is(Provider)(env.provider) ? env.provider : undefined + if (env.provider !== undefined && provider === undefined) + yield* Effect.logWarning("ignoring invalid OPENCODE_WEBSEARCH_PROVIDER", { value: env.provider }) return ConfigService.of({ - provider: Option.getOrUndefined(provider), - enableExa: - (yield* flag("OPENCODE_EXPERIMENTAL")) || - (yield* flag("OPENCODE_ENABLE_EXA")) || - (yield* flag("OPENCODE_EXPERIMENTAL_EXA")), - enableParallel: (yield* flag("OPENCODE_ENABLE_PARALLEL")) || (yield* flag("OPENCODE_EXPERIMENTAL_PARALLEL")), - exaApiKey: Option.getOrUndefined(Option.map(exaApiKey, Redacted.value)), - parallelApiKey: Option.getOrUndefined(Option.map(parallelApiKey, Redacted.value)), + provider, + enableExa: env.experimental || env.enableExa || env.experimentalExa, + enableParallel: env.enableParallel || env.experimentalParallel, + exaApiKey: env.exaApiKey, + parallelApiKey: env.parallelApiKey, }) }), ).pipe(Layer.orDie) diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 55345b1a51..8b7093b213 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from "bun:test" -import { ConfigProvider, Effect, Exit, Layer, Schema } from "effect" +import { ConfigProvider, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -48,12 +48,9 @@ describe("WebSearchTool provider selection", () => { }) const readDefaultConfig = (env: Record) => - Effect.gen(function* () { - return yield* WebSearchTool.ConfigService - }).pipe( - Effect.provide( - WebSearchTool.defaultConfigLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(env)))), - ), + Effect.provide( + WebSearchTool.ConfigService, + WebSearchTool.defaultConfigLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(env)))), ) describe("WebSearchTool default config", () => { @@ -87,9 +84,17 @@ describe("WebSearchTool default config", () => { }) }) - test("fails on an invalid provider instead of silently ignoring it", async () => { - const exit = await Effect.runPromiseExit(readDefaultConfig({ OPENCODE_WEBSEARCH_PROVIDER: "bing" })) - expect(Exit.isFailure(exit)).toBe(true) + test("keeps the legacy lenient truthiness grammar", async () => { + const decoded = await Effect.runPromise( + readDefaultConfig({ OPENCODE_ENABLE_EXA: "TRUE", OPENCODE_ENABLE_PARALLEL: "banana" }), + ) + expect(decoded.enableExa).toBe(true) + expect(decoded.enableParallel).toBe(false) + }) + + test("ignores an invalid provider instead of failing the Location layer", async () => { + const decoded = await Effect.runPromise(readDefaultConfig({ OPENCODE_WEBSEARCH_PROVIDER: "bing" })) + expect(decoded.provider).toBeUndefined() }) }) diff --git a/packages/opencode/test/server/httpapi-exercise/environment.ts b/packages/opencode/test/server/httpapi-exercise/environment.ts index 985215c451..bc40dcabdf 100644 --- a/packages/opencode/test/server/httpapi-exercise/environment.ts +++ b/packages/opencode/test/server/httpapi-exercise/environment.ts @@ -18,6 +18,9 @@ const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB export const exerciseDatabasePath = process.env.OPENCODE_HTTPAPI_EXERCISE_DB ?? path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`) +// Must run before the first Effect Config read anywhere in the process: the +// default ConfigProvider snapshots process.env on first use, so a Config read +// during an earlier module import would freeze the wrong database path. process.env.OPENCODE_DB = exerciseDatabasePath export const original = { From 651c23ba1dab9a3732a38e721e931de95523e17e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 09:36:46 -0400 Subject: [PATCH 3/5] refactor(core): replace Database.path with config-backed configuredPath --- packages/core/src/database/database.ts | 35 ++++++++++-------------- packages/core/test/database-path.test.ts | 17 ++++++------ packages/opencode/src/cli/cmd/db.ts | 4 +-- packages/opencode/test/fixture/db.ts | 3 +- 4 files changed, 26 insertions(+), 33 deletions(-) diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index 78585ce8a8..b9abce70f8 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -4,7 +4,7 @@ import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { layer as sqliteLayer } from "#sqlite" import { Config, Context, Effect, Layer } from "effect" import { Global } from "../global" -import { truthy, truthyConfig } from "../flag/flag" +import { truthyConfig } from "../flag/flag" import { isAbsolute, join } from "path" import { DatabaseMigration } from "./migration" import { InstallationChannel } from "../installation/version" @@ -40,8 +40,7 @@ export function layerFromPath(filename: string) { return layer.pipe(Layer.provide(sqliteLayer({ filename }))) } -/** One placement rule shared by the config-backed layer and the V1 `path()` helper. */ -export function resolvePath(input: { readonly file: string | undefined; readonly disableChannelDb: boolean }) { +function resolvePath(input: { readonly file: string | undefined; readonly disableChannelDb: boolean }) { if (input.file) { if (input.file === ":memory:" || isAbsolute(input.file)) return input.file return join(Global.Path.data, input.file) @@ -51,24 +50,18 @@ export function resolvePath(input: { readonly file: string | undefined; readonly return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`) } -/** V1 compatibility helper; reads the process environment at call time. */ -export function path() { - return resolvePath({ - file: process.env.OPENCODE_DB, - disableChannelDb: truthy("OPENCODE_DISABLE_CHANNEL_DB"), - }) -} +/** + * The database placement every consumer shares, resolved through Effect + * Config so tests and tooling can override it with a ConfigProvider. Used by + * the layer below and by external tooling such as `opencode db`. + */ +export const configuredPath = Effect.gen(function* () { + const file = yield* Config.string("OPENCODE_DB").pipe(Config.withDefault(undefined)) + const disableChannelDb = yield* truthyConfig("OPENCODE_DISABLE_CHANNEL_DB") + return resolvePath({ file, disableChannelDb }) +}).pipe(Effect.orDie) -// Placement is resolved through Effect Config when the layer is built, not at -// module import, so tests and tooling can override it with a ConfigProvider. -// truthyConfig shares the `truthy` grammar so this layer and the V1 `path()` -// helper always agree on the same environment. -const configuredLayer = Layer.unwrap( - Effect.gen(function* () { - const file = yield* Config.string("OPENCODE_DB").pipe(Config.withDefault(undefined)) - const disableChannelDb = yield* truthyConfig("OPENCODE_DISABLE_CHANNEL_DB") - return layerFromPath(resolvePath({ file, disableChannelDb })) - }), -).pipe(Layer.orDie) +// Placement is resolved when the layer is built, not at module import. +const configuredLayer = Layer.unwrap(Effect.map(configuredPath, layerFromPath)) export const node = makeGlobalNode({ service: Service, layer: configuredLayer, deps: [] }) diff --git a/packages/core/test/database-path.test.ts b/packages/core/test/database-path.test.ts index 20ab38957c..0524c4ef42 100644 --- a/packages/core/test/database-path.test.ts +++ b/packages/core/test/database-path.test.ts @@ -6,16 +6,15 @@ import { Global } from "@opencode-ai/core/global" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { tmpdir } from "./fixture/tmpdir" +const resolve = (env: Record) => + Effect.runPromise(Effect.provide(Database.configuredPath, ConfigProvider.layer(ConfigProvider.fromUnknown(env)))) + describe("Database placement", () => { - test("resolves explicit files, relative names, and the channel default", () => { - expect(Database.resolvePath({ file: ":memory:", disableChannelDb: false })).toBe(":memory:") - expect(Database.resolvePath({ file: "/tmp/explicit.db", disableChannelDb: false })).toBe("/tmp/explicit.db") - expect(Database.resolvePath({ file: "relative.db", disableChannelDb: false })).toBe( - path.join(Global.Path.data, "relative.db"), - ) - expect(Database.resolvePath({ file: undefined, disableChannelDb: true })).toBe( - path.join(Global.Path.data, "opencode.db"), - ) + test("resolves explicit files, relative names, and the channel default", async () => { + expect(await resolve({ OPENCODE_DB: ":memory:" })).toBe(":memory:") + expect(await resolve({ OPENCODE_DB: "/tmp/explicit.db" })).toBe("/tmp/explicit.db") + expect(await resolve({ OPENCODE_DB: "relative.db" })).toBe(path.join(Global.Path.data, "relative.db")) + expect(await resolve({ OPENCODE_DISABLE_CHANNEL_DB: "true" })).toBe(path.join(Global.Path.data, "opencode.db")) }) test("reads placement from the active ConfigProvider when the layer is built", async () => { diff --git a/packages/opencode/src/cli/cmd/db.ts b/packages/opencode/src/cli/cmd/db.ts index 9e7e37e18e..b2721974cc 100644 --- a/packages/opencode/src/cli/cmd/db.ts +++ b/packages/opencode/src/cli/cmd/db.ts @@ -35,7 +35,7 @@ const QueryCommand = effectCmd({ } return } - const child = spawn("sqlite3", [Database.path()], { + const child = spawn("sqlite3", [yield* Database.configuredPath], { stdio: "inherit", }) yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve))) @@ -47,7 +47,7 @@ const PathCommand = effectCmd({ describe: "print the database path", instance: false, handler: Effect.fn("Cli.db.path")(function* () { - console.log(Database.path()) + console.log(yield* Database.configuredPath) }), }) diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts index 88f1097f2d..6c7c41be7d 100644 --- a/packages/opencode/test/fixture/db.ts +++ b/packages/opencode/test/fixture/db.ts @@ -1,10 +1,11 @@ import { rm } from "fs/promises" import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { disposeAllInstances } from "./fixture" export async function resetDatabase() { await disposeAllInstances().catch(() => undefined) - const dbPath = Database.path() + const dbPath = await Effect.runPromise(Database.configuredPath) await rm(dbPath, { force: true }).catch(() => undefined) await rm(`${dbPath}-wal`, { force: true }).catch(() => undefined) await rm(`${dbPath}-shm`, { force: true }).catch(() => undefined) From a25ba777ef642c49c63aad600a083ea469b66400 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 08:51:45 -0400 Subject: [PATCH 4/5] feat(sdk): add ConfigProvider seam to embedded OpenCode hosts --- packages/sdk-next/src/opencode.ts | 32 ++++++++++++++++++++----- packages/sdk-next/test/embedded.test.ts | 28 +++++++++++----------- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 096b46d4b7..0c31592247 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -4,14 +4,30 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PermissionSaved } from "@opencode-ai/core/permission/saved" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { createEmbeddedRoutes } from "@opencode-ai/server/routes" -import { Context, Effect, Layer, Scope } from "effect" +import { ConfigProvider, Context, Effect, Layer, Scope } from "effect" import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" -export const create = Effect.fn("OpenCode.create")(function* () { +export interface Options { + /** + * Replaces the ConfigProvider this host's layers read while they are built, + * for example the OPENCODE_DB database placement. The default provider + * snapshots the process environment on first use, so per-host configuration + * must come through this seam rather than env mutation. Compose with + * ConfigProvider.orElse(ConfigProvider.fromEnv()) to keep environment + * fallback. + */ + readonly configProvider?: ConfigProvider.ConfigProvider +} + +export const create = Effect.fn("OpenCode.create")(function* (options?: Options) { const scope = yield* Scope.Scope const memoMap = yield* Layer.makeMemoMap + const withConfig = (layer: Layer.Layer) => + options?.configProvider === undefined + ? layer + : layer.pipe(Layer.provide(ConfigProvider.layer(options.configProvider))) const context = yield* Layer.buildWithMemoMap( - AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, PermissionSaved.node])), + withConfig(AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, PermissionSaved.node]))), memoMap, scope, ) @@ -20,9 +36,11 @@ export const create = Effect.fn("OpenCode.create")(function* () { const web = yield* Effect.acquireRelease( Effect.sync(() => HttpRouter.toWebHandler( - createEmbeddedRoutes().pipe( - HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), - Layer.provide(HttpServer.layerServices), + withConfig( + createEmbeddedRoutes().pipe( + HttpRouter.provideRequest(Layer.succeed(PermissionSaved.Service, permissions)), + Layer.provide(HttpServer.layerServices), + ), ), { disableLogger: true, memoMap }, ), @@ -47,3 +65,5 @@ export type Interface = Effect.Success> export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} export const layer = Layer.effect(Service, create()) + +export const layerWith = (options: Options) => Layer.effect(Service, create(options)) diff --git a/packages/sdk-next/test/embedded.test.ts b/packages/sdk-next/test/embedded.test.ts index 01cbec0e66..773801fb40 100644 --- a/packages/sdk-next/test/embedded.test.ts +++ b/packages/sdk-next/test/embedded.test.ts @@ -1,17 +1,15 @@ -import { afterAll, expect, test } from "bun:test" +import { expect, test } from "bun:test" import { mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" -import { Deferred, Effect, Latch, Option, Schema, Stream } from "effect" +import { ConfigProvider, Deferred, Effect, Latch, Option, Schema, Stream } from "effect" import type { OpenCodeEvent } from "../src" -// The database layer resolves OPENCODE_DB through Effect Config, and the -// default ConfigProvider snapshots the process environment on first use, so -// database placement is process-wide. Point every embedded host in this file -// at one shared temporary database before anything builds a runtime. -const databaseDirectory = await mkdtemp(join(tmpdir(), "opencode-embedded-db-")) -process.env.OPENCODE_DB = join(databaseDirectory, "opencode.sqlite") -afterAll(() => rm(databaseDirectory, { recursive: true, force: true })) +// The default ConfigProvider snapshots the process environment on first use, +// so per-host database placement must come through the create() config seam +// rather than env mutation. +const databaseConfig = (directory: string) => + ConfigProvider.fromUnknown({ OPENCODE_DB: join(directory, "opencode.sqlite") }) test("embedded client uses the real router and handlers", async () => { const directory = await mkdtemp(join(tmpdir(), "opencode-embedded-")) @@ -21,7 +19,7 @@ test("embedded client uses the real router and handlers", async () => { try { const program = Effect.gen(function* () { - const opencode = yield* OpenCode.create() + const opencode = yield* OpenCode.create({ configProvider: databaseConfig(directory) }) yield* opencode.tools.register({ embedded_tool: Tool.make({ description: "Embedded test tool", @@ -103,6 +101,8 @@ test("embedded client uses the real router and handlers", async () => { expect(missingMessage._tag).toBe("MessageNotFoundError") }) await Effect.runPromise(Effect.scoped(program)) + // The database materializes at the path this host's ConfigProvider chose. + expect(await Bun.file(join(directory, "opencode.sqlite")).exists()).toBe(true) } finally { await rm(directory, { recursive: true, force: true }) } @@ -115,7 +115,7 @@ test("Location-owned runner events reach the ready global client", async () => { try { const program = Effect.gen(function* () { - const opencode = yield* OpenCode.create() + const opencode = yield* OpenCode.create({ configProvider: databaseConfig(directory) }) const connected = yield* Latch.make(false) const prompted = yield* Deferred.make() yield* opencode.events.subscribe().pipe( @@ -151,8 +151,8 @@ test("independent embedded hosts do not share live notifications", async () => { try { const program = Effect.gen(function* () { - const first = yield* OpenCode.create() - const second = yield* OpenCode.create() + const first = yield* OpenCode.create({ configProvider: databaseConfig(directory) }) + const second = yield* OpenCode.create({ configProvider: databaseConfig(directory) }) const firstReady = yield* Latch.make(false) const secondReady = yield* Latch.make(false) const firstEvent = yield* Latch.make(false) @@ -197,7 +197,7 @@ test("embedded client is available as a Layer service", async () => { id: sessionID, location: Location.Ref.make({ directory: AbsolutePath.make(directory) }), }) - }).pipe(Effect.provide(OpenCode.layer), Effect.scoped), + }).pipe(Effect.provide(OpenCode.layerWith({ configProvider: databaseConfig(directory) })), Effect.scoped), ) expect(created.id).toBe(sessionID) From dd80b72585ed639dbec1f0e259ed2ab315c22b1b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 2 Jul 2026 09:21:42 -0400 Subject: [PATCH 5/5] fix(sdk): build host ConfigProvider layer once and scope its docs --- packages/sdk-next/src/opencode.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/sdk-next/src/opencode.ts b/packages/sdk-next/src/opencode.ts index 0c31592247..2f0e647877 100644 --- a/packages/sdk-next/src/opencode.ts +++ b/packages/sdk-next/src/opencode.ts @@ -9,10 +9,12 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http" export interface Options { /** - * Replaces the ConfigProvider this host's layers read while they are built, - * for example the OPENCODE_DB database placement. The default provider - * snapshots the process environment on first use, so per-host configuration - * must come through this seam rather than env mutation. Compose with + * Replaces the ConfigProvider read while this host's process-global layers + * are built, for example the OPENCODE_DB database placement. The default + * provider snapshots the process environment on first use, so per-host + * configuration must come through this seam rather than env mutation. + * Location-scoped layers build lazily outside host construction and still + * read the process default. Compose with * ConfigProvider.orElse(ConfigProvider.fromEnv()) to keep environment * fallback. */ @@ -22,10 +24,9 @@ export interface Options { export const create = Effect.fn("OpenCode.create")(function* (options?: Options) { const scope = yield* Scope.Scope const memoMap = yield* Layer.makeMemoMap + const configLayer = options?.configProvider === undefined ? undefined : ConfigProvider.layer(options.configProvider) const withConfig = (layer: Layer.Layer) => - options?.configProvider === undefined - ? layer - : layer.pipe(Layer.provide(ConfigProvider.layer(options.configProvider))) + configLayer === undefined ? layer : layer.pipe(Layer.provide(configLayer)) const context = yield* Layer.buildWithMemoMap( withConfig(AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, PermissionSaved.node]))), memoMap, @@ -64,6 +65,6 @@ export type Interface = Effect.Success> export class Service extends Context.Service()("@opencode-ai/sdk-next/OpenCode") {} -export const layer = Layer.effect(Service, create()) - export const layerWith = (options: Options) => Layer.effect(Service, create(options)) + +export const layer = layerWith({})