diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index ab86a37e74c..78585ce8a87 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 47222a5e40f..f7c1d9aaa46 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 efd126f253a..661ecdcd0d4 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 55345b1a51e..8b7093b213b 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 985215c451a..bc40dcabdfc 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 = {