Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 1d2e75ef49 fix(config): guard env-var JSON parsing against invalid input 2026-05-18 20:42:31 -04:00
13 changed files with 569 additions and 509 deletions
@@ -1,6 +1,8 @@
import { createStore, reconcile } from "solid-js/store"
import { Option, Schema } from "effect"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
import { tryJsonConfig } from "@/util/json"
export type HomeRoute = {
type: "home"
@@ -21,17 +23,25 @@ export type PluginRoute = {
export type Route = HomeRoute | SessionRoute | PluginRoute
const HOME_ROUTE: Route = { type: "home" }
// Schema covers only the env-fed shape — `prompt` is set programmatically and
// not expected from OPENCODE_ROUTE, so we keep it out of validation.
export const RouteSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("home") }),
Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }),
Schema.Struct({
type: Schema.Literal("plugin"),
id: Schema.String,
data: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
])
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: (props: { initialRoute?: Route }) => {
const [store, setStore] = createStore<Route>(
props.initialRoute ??
(process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
}),
)
const fromEnv = tryJsonConfig(process.env["OPENCODE_ROUTE"], RouteSchema, "OPENCODE_ROUTE")
const [store, setStore] = createStore<Route>(props.initialRoute ?? Option.getOrElse(fromEnv, () => HOME_ROUTE))
return {
get data() {
+15
View File
@@ -106,6 +106,21 @@ export function FormatError(input: unknown): string | undefined {
].join("\n")
}
// InvalidConfigError: malformed env-var JSON at a safety boundary
if (isTaggedError(input, "InvalidConfigError")) {
const source = stringField(input, "source") ?? ""
const value = stringField(input, "value") ?? ""
const reason = stringField(input, "reason") ?? ""
return [
`Error: ${source} is invalid and cannot be applied.`,
` Value: '${value}'`,
` Reason: ${reason}`,
"",
`opencode will not start with a malformed ${source}. Set ${source}`,
`correctly or unset it to use defaults.`,
].join("\n")
}
// UICancelledError: user cancelled an interactive CLI prompt
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return ""
+3 -1
View File
@@ -14,6 +14,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
import { existsSync } from "fs"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import { requireJsonConfig } from "@/util/json"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
@@ -703,7 +704,8 @@ export const layer = Layer.effect(
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
const decoded = requireJsonConfig(Flag.OPENCODE_PERMISSION, ConfigPermission.Info, "OPENCODE_PERMISSION")
result.permission = mergeDeep(result.permission ?? {}, decoded)
}
if (result.tools) {
+49
View File
@@ -0,0 +1,49 @@
import { Option, Result, Schema } from "effect"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "config" })
const decodeJsonUnknown = Schema.decodeUnknownResult(Schema.UnknownFromJsonString)
export class InvalidConfigError extends Schema.TaggedErrorClass<InvalidConfigError>()("InvalidConfigError", {
source: Schema.String,
value: Schema.String,
reason: Schema.String,
}) {}
// Decode JSON config at a safety boundary. On malformed JSON or schema
// mismatch, throws `InvalidConfigError`. On success, returns the typed value.
// Use when defaulting silently is dangerous (e.g. permissions).
export function requireJsonConfig<S extends Schema.Decoder<unknown>>(raw: string, schema: S, source: string): S["Type"] {
const decoded = decode(raw, schema)
if (Result.isFailure(decoded)) {
throw new InvalidConfigError({ source, value: raw, reason: decoded.failure })
}
return decoded.success
}
// Decode JSON config at a UX-preference boundary. Returns `None` for absent,
// empty, malformed, or schema-mismatched input, logging a warn so the user
// can debug. Use when defaulting silently is benign (e.g. theme, route).
export function tryJsonConfig<S extends Schema.Decoder<unknown>>(
raw: string | undefined,
schema: S,
source: string,
): Option.Option<S["Type"]> {
if (!raw) return Option.none()
const decoded = decode(raw, schema)
if (Result.isFailure(decoded)) {
log.warn(`ignoring invalid ${source}`, { value: raw, reason: decoded.failure })
return Option.none()
}
return Option.some(decoded.success)
}
function decode<S extends Schema.Decoder<unknown>>(raw: string, schema: S): Result.Result<S["Type"], string> {
const decodeSchema = Schema.decodeUnknownResult(schema)
const parsed = decodeJsonUnknown(raw)
if (Result.isFailure(parsed)) return Result.fail(parsed.failure.toString())
const decoded = decodeSchema(parsed.success)
if (Result.isFailure(decoded)) return Result.fail(decoded.failure.toString())
return Result.succeed(decoded.success)
}
@@ -1,84 +0,0 @@
// Subprocess integration tests for `opencode run` (non-interactive mode).
// These exercise the real CLI binary against a TestLLMServer running in the
// same process. See `test/lib/run-process.ts` for the harness — each test uses
// `opencode.run(message, opts?)` to spawn `bun src/index.ts run ...` with
// `OPENCODE_CONFIG_CONTENT` providing the test provider config inline.
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { runIt } from "../../lib/run-process"
describe("opencode run (non-interactive subprocess)", () => {
// Happy path: prompt completes, output reaches stdout, process exits 0.
// If this fails, all the others likely will too — debug here first.
runIt.live(
"exits 0 and writes the response to stdout on a successful prompt",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.text("hello from the test llm")
const result = yield* opencode.run("say hi")
opencode.expectExit(result, 0)
expect(result.stdout).toContain("hello from the test llm")
}),
60_000,
)
// Regression for #27371: an unknown model used to hang the process forever
// waiting on a session.status === idle event that never arrived. The fix
// makes the SDK call surface an error promptly so the process exits nonzero.
// We assert nonzero exit AND wall-clock under the harness timeout — a hang
// would expire the timeout and produce a different (signal-killed) failure.
runIt.live(
"exits nonzero promptly when the model is unknown (regression for #27371)",
({ opencode }) =>
Effect.gen(function* () {
const result = yield* opencode.run("say hi", {
model: "test/nonexistent-model",
timeoutMs: 15_000,
})
expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(15_000)
}),
30_000,
)
// Locks in the current behavior: when the LLM stream errors mid-response
// (the prompt was accepted, then the upstream provider failed), opencode
// emits a session.error event and the process exits 0 today.
//
// This is debatable — a future cleanup might flip it to exit 1. If you're
// changing this expectation, do it deliberately and say so in the PR.
runIt.live(
"mid-stream LLM error still exits 0 today (contract lock-in)",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.fail("upstream provider exploded mid-stream")
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
expect(result.exitCode).toBe(0)
}),
60_000,
)
// --format json puts one JSON object per line on stdout for each emitted
// event. Consumers (CI scripts, tooling) parse this stream. Asserts the
// shape so a future event-emit change has to update this expectation.
runIt.live(
"--format json emits parseable line-delimited JSON to stdout",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.text("structured output")
const result = yield* opencode.run("say hi", { format: "json" })
opencode.expectExit(result, 0)
const events = opencode.parseJsonEvents(result.stdout)
expect(events.length).toBeGreaterThan(0)
for (const evt of events) {
expect(typeof evt.type).toBe("string")
expect(typeof evt.sessionID).toBe("string")
}
// At least one `text` event should appear with the LLM's response.
const text = events.find((e) => e.type === "text")
expect(text).toBeDefined()
}),
60_000,
)
})
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { Option } from "effect"
import { RouteSchema } from "@/cli/cmd/tui/context/route"
import { tryJsonConfig } from "@/util/json"
const parse = (raw: string | undefined) => tryJsonConfig(raw, RouteSchema, "OPENCODE_ROUTE")
describe("tryJsonConfig with RouteSchema", () => {
test("returns None for undefined input", () => {
expect(Option.isNone(parse(undefined))).toBe(true)
})
test("returns None for empty string", () => {
expect(Option.isNone(parse(""))).toBe(true)
})
test("returns None for malformed JSON", () => {
expect(Option.isNone(parse("{not json"))).toBe(true)
})
test("returns None when JSON does not match route schema", () => {
expect(Option.isNone(parse(`{"type":"unknown"}`))).toBe(true)
})
test("returns Some for valid home route", () => {
expect(Option.getOrThrow(parse(`{"type":"home"}`))).toEqual({ type: "home" })
})
test("returns Some for valid session route", () => {
expect(Option.getOrThrow(parse(`{"type":"session","sessionID":"abc123"}`))).toEqual({
type: "session",
sessionID: "abc123",
})
})
test("returns Some for valid plugin route", () => {
expect(Option.getOrThrow(parse(`{"type":"plugin","id":"foo","data":{"x":1}}`))).toEqual({
type: "plugin",
id: "foo",
data: { x: 1 },
})
})
})
+276 -189
View File
@@ -1,5 +1,5 @@
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
import { Effect, Exit, Layer, Option } from "effect"
import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "@/config/config"
import { ConfigManaged } from "@/config/managed"
@@ -13,7 +13,7 @@ import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Env } from "../../src/env"
import { provideTestInstance, provideTmpdirInstance, TestInstance, withTestInstance } from "../fixture/fixture"
import { provideTestInstance, provideTmpdirInstance, withTestInstance } from "../fixture/fixture"
import { tmpdir } from "../fixture/fixture"
import { InstanceRuntime } from "@/project/instance-runtime"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
@@ -94,6 +94,14 @@ const listDirs = (ctx: InstanceContext) =>
Effect.provide(layer),
),
)
const ready = (ctx: InstanceContext) =>
Effect.runPromise(
Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe(
Effect.scoped,
Effect.provide(layer),
),
)
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
@@ -115,25 +123,6 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
await Filesystem.write(path.join(dir, name), JSON.stringify(config))
}
const writeConfigEffect = (dir: string, config: object, name = "opencode.json") =>
Effect.promise(() => writeConfig(dir, config, name))
function withProcessEnv<A, E, R>(key: string, value: string, effect: Effect.Effect<A, E, R>) {
return Effect.acquireUseRelease(
Effect.sync(() => {
const original = process.env[key]
process.env[key] = value
return original
}),
() => effect,
(original) =>
Effect.sync(() => {
if (original !== undefined) process.env[key] = original
else delete process.env[key]
}),
)
}
async function check(map: (dir: string) => string) {
if (process.platform !== "win32") return
await using globalTmp = await tmpdir()
@@ -221,24 +210,43 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () =
}
})
it.instance(
"loads JSON config file",
Effect.gen(function* () {
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
}),
{ config: { model: "test/model", username: "testuser" } },
)
test("loads JSON config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
model: "test/model",
username: "testuser",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
})
})
it.instance(
"loads shell config field",
Effect.gen(function* () {
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.shell).toBe("bash")
}),
{ config: { shell: "bash" } },
)
test("loads shell config field", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
shell: "bash",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.shell).toBe("bash")
},
})
})
test("updates config and preserves empty shell sentinel", async () => {
await using tmp = await tmpdir({
@@ -322,23 +330,41 @@ test("updates global config and omits empty shell key in jsonc", async () => {
}
})
it.instance(
"loads formatter boolean config",
Effect.gen(function* () {
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.formatter).toBe(true)
}),
{ config: { formatter: true } },
)
test("loads formatter boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
formatter: true,
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.formatter).toBe(true)
},
})
})
it.instance(
"loads lsp boolean config",
Effect.gen(function* () {
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.lsp).toBe(true)
}),
{ config: { lsp: true } },
)
test("loads lsp boolean config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
lsp: true,
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.lsp).toBe(true)
},
})
})
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
@@ -379,118 +405,124 @@ test("ignores legacy tui keys in opencode config", async () => {
})
})
it.instance("loads JSONC config file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Filesystem.write(
path.join(test.directory, "opencode.jsonc"),
test("loads JSONC config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(
path.join(dir, "opencode.jsonc"),
`{
// This is a comment
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"username": "testuser"
}`,
),
)
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
}),
)
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
})
})
it.instance("jsonc overrides json in the same directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(
test.directory,
{
test("jsonc overrides json in the same directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(
dir,
{
$schema: "https://opencode.ai/config.json",
model: "base",
username: "base",
},
"opencode.jsonc",
)
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
model: "base",
username: "base",
},
"opencode.jsonc",
)
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
model: "override",
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.model).toBe("base")
expect(config.username).toBe("base")
}),
)
it.instance("handles environment variable substitution", () =>
withProcessEnv(
"TEST_VAR",
"test-user",
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
username: "{env:TEST_VAR}",
model: "override",
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.username).toBe("test-user")
}),
),
)
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.model).toBe("base")
expect(config.username).toBe("base")
},
})
})
it.instance("preserves env variables when adding $schema to config", () =>
withProcessEnv(
"PRESERVE_VAR",
"secret_value",
Effect.gen(function* () {
const test = yield* TestInstance
// Config without $schema - should trigger auto-add
yield* Effect.promise(() =>
Filesystem.write(
path.join(test.directory, "opencode.json"),
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test-user"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{env:TEST_VAR}",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test-user")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["TEST_VAR"] = originalEnv
} else {
delete process.env["TEST_VAR"]
}
}
})
test("preserves env variables when adding $schema to config", async () => {
const originalEnv = process.env["PRESERVE_VAR"]
process.env["PRESERVE_VAR"] = "secret_value"
try {
await using tmp = await tmpdir({
init: async (dir) => {
// Config without $schema - should trigger auto-add
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
username: "{env:PRESERVE_VAR}",
}),
),
)
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = yield* Effect.promise(() => Filesystem.readText(path.join(test.directory, "opencode.json")))
expect(content).toContain("{env:PRESERVE_VAR}")
expect(content).not.toContain("secret_value")
expect(content).toContain("$schema")
}),
),
)
it.instance("handles file inclusion substitution", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "included.txt"), "test-user"))
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.txt}",
)
},
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.username).toBe("test-user")
}),
)
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("secret_value")
it.instance("handles file inclusion with replacement tokens", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
Filesystem.write(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`"),
)
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.md}",
// Read the file to verify the env variable was preserved
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
expect(content).toContain("{env:PRESERVE_VAR}")
expect(content).not.toContain("secret_value")
expect(content).toContain("$schema")
},
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.username).toBe("const out = await Bun.$`echo hi`")
}),
)
} finally {
if (originalEnv !== undefined) {
process.env["PRESERVE_VAR"] = originalEnv
} else {
delete process.env["PRESERVE_VAR"]
}
}
})
test("resolves env templates in account config with account token", async () => {
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
@@ -557,50 +589,105 @@ test("resolves env templates in account config with account token", async () =>
}
})
it.instance("validates config schema and throws on invalid fields", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
invalid_field: "should cause error",
})
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "included.txt"), "test-user")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.txt}",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("test-user")
},
})
})
it.instance("throws error for invalid JSON", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }"))
const exit = yield* Config.Service.use((svc) => svc.get()).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
test("handles file inclusion with replacement tokens", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.md}",
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.username).toBe("const out = await Bun.$`echo hi`")
},
})
})
it.instance("handles agent configuration", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
test("validates config schema and throws on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
invalid_field: "should cause error",
})
},
})
await provideTestInstance({
directory: tmp.path,
fn: async (ctx) => {
// Strict schema should throw an error for invalid fields
await expect(load(ctx)).rejects.toThrow()
},
})
})
test("throws error for invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }")
},
})
await provideTestInstance({
directory: tmp.path,
fn: async (ctx) => {
await expect(load(ctx)).rejects.toThrow()
},
})
})
test("handles agent configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "test/model",
temperature: 0.7,
description: "test agent",
},
},
})
},
})
await withTestInstance({
directory: tmp.path,
fn: async (ctx) => {
const config = await load(ctx)
expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({
model: "test/model",
temperature: 0.7,
description: "test agent",
},
},
})
const config = yield* Config.Service.use((svc) => svc.get())
expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({
model: "test/model",
temperature: 0.7,
description: "test agent",
}),
)
}),
)
}),
)
},
})
})
test("treats agent variant as model-scoped setting (not provider option)", async () => {
await using tmp = await tmpdir({
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import { ConfigPermission } from "@/config/permission"
import { InvalidConfigError, requireJsonConfig } from "@/util/json"
const decode = (raw: string) => requireJsonConfig(raw, ConfigPermission.Info, "OPENCODE_PERMISSION")
describe("requireJsonConfig with ConfigPermission.Info", () => {
test("throws InvalidConfigError on malformed JSON", () => {
let caught: unknown
try {
decode("{not json")
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const data = caught as InvalidConfigError
expect(data.source).toBe("OPENCODE_PERMISSION")
expect(data.value).toBe("{not json")
expect(typeof data.reason).toBe("string")
expect(data.reason.length).toBeGreaterThan(0)
})
test("throws InvalidConfigError on shape mismatch", () => {
let caught: unknown
try {
// `banana` is not a valid Action ("ask" | "allow" | "deny")
decode(`{"bash":"banana"}`)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const data = caught as InvalidConfigError
expect(data.source).toBe("OPENCODE_PERMISSION")
expect(data.value).toBe(`{"bash":"banana"}`)
expect(data.reason.length).toBeGreaterThan(0)
})
test("returns decoded value for Action shorthand", () => {
expect(decode(`"ask"`)).toEqual({ "*": "ask" })
})
test("returns decoded value for object of per-target rules", () => {
expect(decode(`{"bash":"ask","edit":"allow"}`)).toEqual({
bash: "ask",
edit: "allow",
})
})
test("returns decoded value for nested Object rule", () => {
expect(decode(`{"bash":{"rm *":"deny","ls":"allow"}}`)).toEqual({
bash: { "rm *": "deny", ls: "allow" },
})
})
})
-185
View File
@@ -1,185 +0,0 @@
// Subprocess test harness for the `opencode run` CLI.
//
// This is the missing test tier: every other `cli/run/*.test.ts` is a unit
// test of an extracted helper. Nothing actually exercises the `RunCommand`
// handler end-to-end. Bugs that span argv parsing → server boot → SDK call →
// event consumption → exit code (like the original /event race or the
// non-interactive hang #27371) are invisible to in-process tests.
//
// The harness uses opencode's built-in test affordances to spawn the real CLI
// hermetically:
// - OPENCODE_CONFIG_CONTENT : provider config inline, no files to find
// - OPENCODE_TEST_HOME : pins os.homedir() → tmpdir
// - OPENCODE_DISABLE_PROJECT_CONFIG : skip walking up for opencode.json
// - OPENCODE_PURE : skip external plugin discovery + install
// - OPENCODE_DISABLE_AUTOUPDATE / AUTOCOMPACT / MODELS_FETCH : no background work
//
// Plus HOME / XDG_* pointing at the tmpdir for belt-and-suspenders isolation.
//
// The custom `test` provider points at a TestLLMServer running in the same
// process at a random port. The CLI subprocess talks to it over real HTTP.
import type { TestOptions } from "bun:test"
import * as Scope from "effect/Scope"
import { Effect } from "effect"
import path from "node:path"
import fs from "node:fs/promises"
import os from "node:os"
import { Process } from "@/util/process"
import { TestLLMServer } from "./llm-server"
import { testProviderConfig } from "./test-provider"
import { it } from "./effect"
const opencodeRoot = path.resolve(import.meta.dir, "../../")
const cliEntry = path.join(opencodeRoot, "src/index.ts")
export const testModelID = "test/test-model"
function isolatedEnv(home: string, configJson: string): Record<string, string> {
return {
OPENCODE_TEST_HOME: home,
HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_DATA_HOME: path.join(home, ".local/share"),
XDG_STATE_HOME: path.join(home, ".local/state"),
XDG_CACHE_HOME: path.join(home, ".cache"),
OPENCODE_CONFIG_CONTENT: configJson,
OPENCODE_DISABLE_PROJECT_CONFIG: "1",
OPENCODE_PURE: "1",
OPENCODE_DISABLE_AUTOUPDATE: "1",
OPENCODE_DISABLE_AUTOCOMPACT: "1",
OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_AUTH_CONTENT: "{}",
}
}
export type RunResult = {
readonly exitCode: number
readonly stdout: string
readonly stderr: string
readonly durationMs: number
}
type SpawnOpts = { readonly timeoutMs?: number; readonly env?: Record<string, string> }
// A `RunOpts` is the typed equivalent of constructing argv for `opencode run`.
// New flags should land here so tests stay grep-able and refactor-safe.
export type RunOpts = SpawnOpts & {
readonly model?: string
readonly agent?: string
readonly format?: "default" | "json"
readonly command?: string
readonly printLogs?: boolean
readonly extraArgs?: string[]
}
export type OpencodeCli = {
// High-level: run a single prompt against the test model.
readonly run: (message: string, opts?: RunOpts) => Effect.Effect<RunResult>
// Escape hatch: any CLI invocation with full control over argv.
readonly spawn: (args: string[], opts?: SpawnOpts) => Effect.Effect<RunResult>
// Convenience assertion. Dumps captured stderr/stdout on mismatch so CI
// failures are debuggable without re-running locally.
readonly expectExit: (result: RunResult, expected: number, label?: string) => void
// Parse `--format json` stdout into one event object per non-empty line.
// The CLI writes `JSON.stringify({ type, sessionID, ... }) + EOL` for each
// event (see src/cli/cmd/run.ts `emit`). Throws if any line is malformed
// so tests fail loudly rather than silently skipping data.
readonly parseJsonEvents: (stdout: string) => Array<Record<string, unknown>>
}
export type RunFixture = {
readonly llm: TestLLMServer["Service"]
readonly home: string
readonly opencode: OpencodeCli
}
// `withRunFixture(fn)` provisions a TestLLMServer + tmpdir + spawn helper and
// invokes fn. Cleans up the tmpdir on scope exit.
//
// Note on the R channel: TestLLMServer.layer is provided internally so the
// caller doesn't need to wire it up. The fixture's lifetime is tied to the
// surrounding Scope.
export function withRunFixture<A, E>(
fn: (input: RunFixture) => Effect.Effect<A, E>,
): Effect.Effect<A, E | unknown, Scope.Scope> {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
const home = path.join(os.tmpdir(), "oc-run-" + Math.random().toString(36).slice(2))
yield* Effect.promise(() => fs.mkdir(home, { recursive: true }))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(home, { recursive: true, force: true }).catch(() => undefined)),
)
const configJson = JSON.stringify(testProviderConfig(llm.url))
const env = isolatedEnv(home, configJson)
const spawn = (args: string[], opts?: SpawnOpts): Effect.Effect<RunResult> =>
Effect.promise(async () => {
const start = Date.now()
// Process.run pipes stdout/stderr by default and returns them as Buffers.
const result = await Process.run(["bun", "run", "--conditions=browser", cliEntry, ...args], {
cwd: home,
timeout: opts?.timeoutMs ?? 30_000,
env: { ...process.env, ...env, ...opts?.env },
nothrow: true,
})
return {
exitCode: result.code,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
durationMs: Date.now() - start,
}
})
const run = (message: string, opts?: RunOpts): Effect.Effect<RunResult> => {
const argv: string[] = ["run"]
if (opts?.printLogs) argv.push("--print-logs")
argv.push("--model", opts?.model ?? testModelID)
if (opts?.agent) argv.push("--agent", opts.agent)
if (opts?.format) argv.push("--format", opts.format)
if (opts?.command) argv.push("--command", opts.command)
if (opts?.extraArgs) argv.push(...opts.extraArgs)
argv.push(message)
return spawn(argv, opts)
}
const opencode: OpencodeCli = { run, spawn, expectExit, parseJsonEvents }
return yield* fn({ llm, home, opencode })
}).pipe(Effect.provide(TestLLMServer.layer))
}
function parseJsonEvents(stdout: string): Array<Record<string, unknown>> {
return stdout
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>)
}
// Convenience for the common assertion pattern. Dumps stderr/stdout when
// the exit code doesn't match — saves debugging time on CI failures.
function expectExit(result: RunResult, expected: number, label = "opencode") {
if (result.exitCode === expected) return
const tail = (s: string, n: number) => (s.length > n ? "..." + s.slice(-n) : s)
// eslint-disable-next-line no-console
console.error(`[${label}] expected exit ${expected}, got ${result.exitCode} after ${result.durationMs}ms`)
// eslint-disable-next-line no-console
console.error(`[${label}] stderr (last 2000):\n${tail(result.stderr, 2000)}`)
// eslint-disable-next-line no-console
console.error(`[${label}] stdout (last 500):\n${tail(result.stdout, 500)}`)
throw new Error(`${label}: expected exit ${expected}, got ${result.exitCode}`)
}
// `runIt.live(name, fixture => effect)` is the same as
// `it.live(name, () => withRunFixture(fixture))` — one fewer nesting level at
// every call site. Use this for any test that needs the opencode CLI fixture.
//
// Only `.live` is exposed because subprocess tests must run against the real
// clock — a TestClock-paused environment can't drive a child process. If you
// need `.only` or `.skip`, fall back to `it.live` + `withRunFixture` directly.
export const runIt = {
live: <A, E>(name: string, body: (input: RunFixture) => Effect.Effect<A, E>, opts?: number | TestOptions) =>
it.live(name, () => withRunFixture(body), opts),
}
@@ -1,37 +0,0 @@
// Shared provider config for tests that need opencode to talk to a fake LLM
// over a real HTTP endpoint. Registers a single provider `test` with a single
// model `test-model` (i.e. `--model test/test-model`), pointed at the URL the
// caller supplies (typically a TestLLMServer instance).
//
// Used by:
// - test/lib/run-process.ts (subprocess CLI tests)
// - test/server/httpapi-sdk.test.ts (in-process SDK tests)
export function testProviderConfig(llmUrl: string) {
return {
formatter: false,
lsp: false,
provider: {
test: {
name: "Test",
id: "test",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
"test-model": {
id: "test-model",
name: "Test Model",
attachment: false,
reasoning: false,
temperature: false,
tool_call: true,
release_date: "2025-01-01",
limit: { context: 100_000, output: 10_000 },
cost: { input: 0, output: 0 },
options: {},
},
},
options: { apiKey: "test-key", baseURL: llmUrl },
},
},
}
}
@@ -23,7 +23,6 @@ import path from "path"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { awaitWithTimeout, testEffect } from "../lib/effect"
import { testProviderConfig } from "../lib/test-provider"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect(
@@ -100,6 +99,39 @@ function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
function providerConfig(url: string) {
return {
formatter: false,
lsp: false,
provider: {
test: {
name: "Test",
id: "test",
env: [],
npm: "@ai-sdk/openai-compatible",
models: {
"test-model": {
id: "test-model",
name: "Test Model",
attachment: false,
reasoning: false,
temperature: false,
tool_call: true,
release_date: "2025-01-01",
limit: { context: 100000, output: 10000 },
cost: { input: 0, output: 0 },
options: {},
},
},
options: {
apiKey: "test-key",
baseURL: url,
},
},
},
}
}
function call<T>(request: () => Promise<T>) {
return Effect.promise(request)
}
@@ -251,7 +283,7 @@ function withStandardProject<A, E>(
function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* withProject(serverPath, { config: testProviderConfig(llm.url) }, (input) => run({ ...input, llm }))
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
}).pipe(Effect.provide(TestLLMServer.layer))
}
@@ -265,7 +297,7 @@ function withFakeLlmProject<A, E>(
return yield* withProject(
serverPath,
{
config: testProviderConfig(llm.url),
config: providerConfig(llm.url),
setup: options.setup,
},
(input) => run({ ...input, llm }),
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import { Option, Schema } from "effect"
import { InvalidConfigError, requireJsonConfig, tryJsonConfig } from "@/util/json"
const PointSchema = Schema.Struct({
x: Schema.Number,
y: Schema.Number,
})
describe("requireJsonConfig", () => {
test("returns decoded value on success", () => {
expect(requireJsonConfig(`{"x":1,"y":2}`, PointSchema, "TEST")).toEqual({ x: 1, y: 2 })
})
test("throws InvalidConfigError on malformed JSON", () => {
let caught: unknown
try {
requireJsonConfig("not json", PointSchema, "TEST")
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const err = caught as InvalidConfigError
expect(err.source).toBe("TEST")
expect(err.value).toBe("not json")
expect(err.reason.length).toBeGreaterThan(0)
})
test("throws InvalidConfigError on schema mismatch", () => {
let caught: unknown
try {
requireJsonConfig(`{"x":"oops"}`, PointSchema, "TEST")
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const err = caught as InvalidConfigError
expect(err.source).toBe("TEST")
expect(err.value).toBe(`{"x":"oops"}`)
expect(err.reason.length).toBeGreaterThan(0)
})
test("InvalidConfigError carries _tag for downstream matching", () => {
let caught: unknown
try {
requireJsonConfig("not json", PointSchema, "TEST")
} catch (error) {
caught = error
}
expect((caught as { _tag: string })._tag).toBe("InvalidConfigError")
})
})
describe("tryJsonConfig", () => {
test("returns Some on success", () => {
const decoded = tryJsonConfig(`{"x":1,"y":2}`, PointSchema, "TEST")
expect(Option.isSome(decoded)).toBe(true)
expect(Option.getOrThrow(decoded)).toEqual({ x: 1, y: 2 })
})
test("returns None for undefined input", () => {
expect(Option.isNone(tryJsonConfig(undefined, PointSchema, "TEST"))).toBe(true)
})
test("returns None for empty string", () => {
expect(Option.isNone(tryJsonConfig("", PointSchema, "TEST"))).toBe(true)
})
test("returns None for malformed JSON", () => {
expect(Option.isNone(tryJsonConfig("not json", PointSchema, "TEST"))).toBe(true)
})
test("returns None on schema mismatch", () => {
expect(Option.isNone(tryJsonConfig(`{"x":"oops"}`, PointSchema, "TEST"))).toBe(true)
})
})
-2
View File
@@ -71,8 +71,6 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
| First provider config/env/filtering block can use Effect-aware instance fixtures | Migrated six `tmpdir` + `withTestInstance` cases to `it.instance` | 6.06s | 6.07s | keep | Neutral timing, but removes manual config file writes and instance plumbing; use as the pattern for later provider slices. |
| Custom provider/model config cases can use Effect-aware instance fixtures | Migrated three more config-heavy provider cases to `it.instance` | 6.07s | 6.12s | keep | Neutral timing within noise, but continues removing manual config file writes on top of the first provider fixture PR. |
| Provider env precedence and model lookup cases can use Effect-aware instance fixtures | Migrated four more provider lookup/default-model cases to `it.instance` | 6.12s | 6.36s | keep | Noisy 5-run median; kept as a small stacked cleanup slice but do not claim speedup from this migration. |
| Simple config load cases can use Effect-aware instance fixtures | Migrated JSON, shell, formatter, and lsp config load cases to `it.instance` | 14.18s | 3.93s | keep | Three-run medians before/after; removes manual `tmpdir` + `withTestInstance` setup from the first simple config block. |
| Config template, file include, and simple agent cases can use Effect-aware instance fixtures | Migrated JSONC, env/file substitution, invalid config, and agent config cases to `it.instance` | 1.87s | 1.90s | keep | Stacked on the first config slice; neutral timing but removes more manual `tmpdir` + instance plumbing. |
## Profiling Results