Compare commits

...
Author SHA1 Message Date
James Long fdfe599cb5 handle all events 2026-04-30 10:43:06 -04:00
James Long 6df566161e fix(core): run sessions with the same directory they were created with 2026-04-30 10:43:05 -04:00
OpeOginniandGitHub 62e1335388 fix(opencode): allow oc://renderer origin in cors middleware (#25099) 2026-04-30 12:11:42 +00:00
Brendan AllanandGitHub 908e28175f fix: invert *_ready getters to fix server status indicator (#25077) 2026-04-30 15:10:39 +08:00
Brendan AllanandGitHub 3398fd7719 feat(httpapi): add CORS middleware to instance routes (#25074) 2026-04-30 07:06:17 +00:00
Luke ParkerandGitHub 9bddf7f3ef fix app crash restoring messages without model (#25062) 2026-04-30 14:44:53 +10:00
Dax Raad 8ba374fefa ci: enable sourcemaps for beta releases
Generate linked sourcemaps when building beta releases to help users
debug issues with readable stack traces.
2026-04-30 00:42:22 -04:00
Aiden ClineandGitHub 3ef0aaf768 tweak: make azure onboarding ux a bit better (#25057) 2026-04-29 23:35:59 -05:00
Tommy D. RossiandGitHub d7701dbfb6 fix(opencode): preserve external_dir and deny parent permissions in task child sessions (#23290) 2026-04-29 22:06:29 -05:00
Kit LangtonandGitHub c49bf0b402 test: cover ConfigService helper (#25042) 2026-04-30 02:41:59 +00:00
Kit LangtonandGitHub cee9610d26 refactor: use Effect config for HttpApi authorization (#25035) 2026-04-29 22:22:32 -04:00
Kit LangtonandGitHub 38adc13295 test: cover HttpApi authorization middleware (#25033) 2026-04-29 21:34:52 -04:00
Kit LangtonandGitHub 4fe14abb8c test: cover HttpApi instance context middleware (#25032) 2026-04-29 21:24:45 -04:00
Kit LangtonandGitHub 9052e8a1ba test: cover HttpApi workspace routing middleware (#25027) 2026-04-29 21:08:03 -04:00
github-actions[bot] de78dedceb Update VOUCHED list
https://github.com/anomalyco/opencode/issues/23890#issuecomment-4348703527
2026-04-30 00:58:42 +00:00
Kit LangtonandGitHub 6f508d574e test: deflake runner cancel test (#25021) 2026-04-29 20:19:52 -04:00
29 changed files with 1268 additions and 96 deletions
+1
View File
@@ -16,6 +16,7 @@ ariane-emory
-danieljoshuanazareth
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
dmtrkovalenko
edemaine
fahreddinozcan
-florianleibert
+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
- name: Build
id: build
run: |
./packages/opencode/script/build.ts
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
+8
View File
@@ -28,3 +28,11 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
## Testing Patterns
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
@@ -187,7 +187,7 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return providerQuery.isLoading
return !providerQuery.isLoading
},
provider: { all: [], connected: [], default: {} },
config: {},
@@ -207,13 +207,13 @@ export function createChildStoreManager(input: {
permission: {},
question: {},
get mcp_ready() {
return mcpQuery.isLoading
return !mcpQuery.isLoading
},
get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
},
get lsp_ready() {
return lspQuery.isLoading
return !lspQuery.isLoading
},
get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
+1 -1
View File
@@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.model.variant ?? null,
variant: msg.model?.variant ?? null,
})
},
},
+2
View File
@@ -50,6 +50,7 @@ console.log(`Loaded ${migrations.length} migrations`)
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin()
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
@@ -199,6 +200,7 @@ for (const item of targets) {
external: ["node-gyp"],
format: "esm",
minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none",
splitting: true,
compile: {
autoloadBunfig: false,
+28 -18
View File
@@ -156,28 +156,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (method.type === "api") {
if (method.authorize) {
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
})
prompts.log.success("Login successful")
}
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
if (!method.authorize) {
await put(provider, {
type: "api",
key,
...metadata,
})
prompts.outro("Done")
return true
}
const result = await method.authorize(inputs)
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
type: "api",
key: result.key ?? key,
...metadata,
})
prompts.log.success("Login successful")
}
prompts.outro("Done")
return true
}
return false
@@ -12,22 +12,7 @@ export function useEvent() {
return
}
// Special hack for truly global events
if (event.directory === "global") {
handler(event.payload)
}
if (project.workspace.current()) {
if (event.workspace === project.workspace.current()) {
handler(event.payload)
}
return
}
if (event.directory === project.instance.directory()) {
handler(event.payload)
}
handler(event.payload)
})
}
@@ -0,0 +1,67 @@
import { Config, Context, Effect, Layer } from "effect"
type ConfigMap = Record<string, Config.Config<unknown>>
/**
* The service shape inferred from an object of Effect `Config` definitions.
*/
export type Shape<Fields extends ConfigMap> = {
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
}
/**
* A Context service class with generated layers for config-backed services.
*/
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
/** Provide already-parsed config, useful in tests. */
readonly layer: (input: Service) => Layer.Layer<Self>
/** Parse config once from the active Effect ConfigProvider and provide the service. */
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
}
/**
* Create a Context service whose implementation is derived from Effect `Config`.
*
* This keeps Effect `Config` as the source of truth for env names, defaults, and
* validation while generating a typed service plus convenient production/test
* layers.
*
* ```ts
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
* "@opencode/ServerAuthConfig",
* {
* password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
* username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
* },
* ) {}
*
* const live = ServerAuthConfig.defaultLayer
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
* ```
*/
export const Service =
<Self>() =>
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
static layer(input: Shape<Fields>) {
return Layer.succeed(this, this.of(input))
}
static get defaultLayer() {
return Layer.effect(
this,
Config.all(fields)
.asEffect()
.pipe(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
Effect.map((config) => this.of(config as Shape<Fields>)),
),
)
}
}
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
}
export * as ConfigService from "./config-service"
@@ -0,0 +1,38 @@
import { Context, Effect } from "effect"
type EffectMethod = (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
type ServiceUse<Identifier, Shape> = {
readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends (
...args: infer Args
) => infer Return
? Args extends ReadonlyArray<unknown>
? Return extends Effect.Effect<infer A, infer E, infer R>
? (...args: Args) => Effect.Effect<A, E, R | Identifier>
: never
: never
: never
}
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
// This is the only dynamic boundary: TypeScript knows the accessor shape,
// but Proxy property names are runtime values.
const access = new Proxy(
{},
{
get: (_, key) => {
if (typeof key !== "string") return undefined
return (...args: unknown[]) =>
tag.use((service) => {
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
const method = service[key as keyof Shape]
if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`))
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
})
},
},
)
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily.
return access as ServiceUse<Identifier, Shape>
}
+26
View File
@@ -0,0 +1,26 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
export async function AzureAuthPlugin(_input: PluginInput): Promise<Hooks> {
const prompts = []
if (!process.env.AZURE_RESOURCE_NAME) {
prompts.push({
type: "text" as const,
key: "resourceName",
message: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
})
}
return {
auth: {
provider: "azure",
methods: [
{
type: "api",
label: "API key",
prompts,
},
],
},
}
}
+2
View File
@@ -17,6 +17,7 @@ import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
import { AzureAuthPlugin } from "./azure"
import { Effect, Layer, Context, Stream } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
@@ -61,6 +62,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
]
function isServerPlugin(value: unknown): value is PluginInstance {
+4 -1
View File
@@ -17,6 +17,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { serviceUse } from "@/effect/service-use"
const log = Log.create({ service: "project" })
@@ -178,7 +179,7 @@ export const layer: Layer.Layer<
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.map((x) => ProjectID.make(x)),
Effect.catch(() => Effect.void),
)
})
@@ -485,6 +486,8 @@ export const defaultLayer = layer.pipe(
Layer.provide(NodePath.layer),
)
export const use = serviceUse(Service)
export function list() {
return Database.use((db) =>
db
+26 -7
View File
@@ -199,12 +199,26 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
}),
azure: Effect.fnUntraced(function* (provider: Info) {
const env = yield* dep.env()
const auth = yield* dep.auth(provider.id)
const resource = iife(() => {
const name = provider.options?.resourceName
if (typeof name === "string" && name.trim() !== "") return name
return env["AZURE_RESOURCE_NAME"]
return [
provider.options?.resourceName,
auth?.type === "api" ? auth.metadata?.resourceName : undefined,
env["AZURE_RESOURCE_NAME"],
].find((name) => typeof name === "string" && name.trim() !== "")
})
if (!resource && !provider.options?.baseURL) {
return {
autoload: false,
async getModel() {
throw new Error(
"AZURE_RESOURCE_NAME is missing, set it using env var or reconnecting the azure provider and setting it",
)
},
}
}
return {
autoload: false,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
@@ -215,11 +229,16 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
return sdk.responses(modelID)
}
},
options: {},
vars(_options) {
return {
...(resource && { AZURE_RESOURCE_NAME: resource }),
options: {
resourceName: resource,
},
vars(_options): Record<string, string> {
if (resource) {
return {
AZURE_RESOURCE_NAME: resource,
}
}
return {}
},
}
}),
@@ -74,6 +74,7 @@ export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (input.startsWith("oc://renderer")) return input
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
return input
@@ -1,17 +1,11 @@
import { Effect, Encoding, Layer, Redacted, Schema } from "effect"
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { Flag } from "@opencode-ai/core/flag/flag"
class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}
import { ConfigService } from "@/effect/config-service"
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
"@opencode/ExperimentalHttpApiAuthorization",
{
error: Unauthorized,
error: HttpApiError.UnauthorizedNoContent,
security: {
basic: HttpApiSecurity.basic,
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
@@ -19,29 +13,38 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
},
) {}
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
"@opencode/ExperimentalHttpApiServerAuthConfig",
{
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
},
) {}
function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
) {
return Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
if (Option.isNone(config.password) || config.password.value === "") return yield* effect
if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
return yield* new Unauthorized({ message: "Unauthorized" })
if (credential.username !== config.username) {
return yield* new HttpApiError.Unauthorized({})
}
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
return yield* new Unauthorized({ message: "Unauthorized" })
if (Redacted.value(credential.password) !== config.password.value) {
return yield* new HttpApiError.Unauthorized({})
}
return yield* effect
})
}
function decodeCredential(input: string) {
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
return Encoding.decodeBase64String(input)
.asEffect()
.pipe(
@@ -59,13 +62,16 @@ function decodeCredential(input: string) {
)
}
export const authorizationLayer = Layer.succeed(
export const authorizationLayer = Layer.effect(
Authorization,
Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential),
authToken: (effect, { credential }) =>
Effect.gen(function* () {
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
}),
Effect.gen(function* () {
const config = yield* ServerAuthConfig
return Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential, config),
authToken: (effect, { credential }) =>
decodeCredential(Redacted.value(credential)).pipe(
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
),
})
}),
)
@@ -32,7 +32,7 @@ import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { authorizationLayer } from "./middleware/authorization"
import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization"
import { eventRoute } from "./event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
@@ -55,8 +55,9 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import * as ServerBackend from "@/server/backend"
import type { Predicate } from "effect/Predicate"
export const context = Context.empty() as Context.Context<unknown>
export const context = Context.makeUnsafe<unknown>(new Map())
const runtime = HttpRouter.middleware()(
Effect.succeed((effect) =>
@@ -97,13 +98,30 @@ const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe(
)
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([
authorizationLayer,
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer,
]),
)
export const routes = Layer.mergeAll(rootApiRoutes, instanceRoutes).pipe(
Layer.provide(
HttpRouter.cors({
maxAge: 86_400,
allowedOrigins: ((input) => {
return (
!input ||
input.startsWith("http://localhost:") ||
input.startsWith("http://127.0.0.1:") ||
input.startsWith("oc://renderer") ||
input === "tauri://localhost" ||
input === "http://tauri.localhost" ||
input === "https://tauri.localhost" ||
/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)
)
}) as Predicate<string> as any,
}),
),
Layer.provide([
runtime,
Account.defaultLayer,
+14 -4
View File
@@ -48,14 +48,14 @@ export function workspaceProxyURL(target: string | URL, requestURL: URL) {
return proxyURL
}
async function getSessionWorkspace(url: URL) {
async function getSession(url: URL) {
const id = getWorkspaceRouteSessionID(url)
if (!id) return null
const session = await AppRuntime.runPromise(
Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
).catch(() => undefined)
return session?.workspaceID
return session
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
@@ -64,10 +64,20 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
return async (c, next) => {
const url = new URL(c.req.url)
const sessionWorkspaceID = await getSessionWorkspace(url)
const workspaceID = sessionWorkspaceID || url.searchParams.get("workspace")
const session = await getSession(url)
const workspaceID = session?.workspaceID || url.searchParams.get("workspace")
if (!workspaceID || url.pathname.startsWith("/console") || Flag.OPENCODE_WORKSPACE_ID) {
if (session) {
return Instance.provide({
directory: session.directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
})
}
return next()
}
+4
View File
@@ -64,12 +64,16 @@ export const TaskTool = Tool.define(
const session = taskID
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const parent = yield* sessions.get(ctx.sessionID)
const nextSession =
session ??
(yield* sessions.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(parent.permission ?? []).filter(
(rule) => rule.permission === "external_directory" || rule.action === "deny",
),
...(canTodo
? []
: [
@@ -0,0 +1,65 @@
import { describe, expect } from "bun:test"
import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect"
import { ConfigService } from "../../src/effect/config-service"
import { it } from "../lib/effect"
class TestConfig extends ConfigService.Service<TestConfig>()("@test/ConfigService", {
name: Config.string("NAME"),
token: Config.string("TOKEN").pipe(Config.option),
port: Config.number("PORT").pipe(Config.withDefault(3000)),
}) {}
const fromConfig = (input: Record<string, unknown>) =>
TestConfig.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input))))
const readConfig = TestConfig.useSync((config) => config)
describe("ConfigService", () => {
it.effect("defaultLayer parses values from the active ConfigProvider", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(
Effect.provide(
fromConfig({
NAME: "kit",
TOKEN: "secret",
PORT: "4096",
}),
),
)
expect(config.name).toBe("kit")
expect(config.token).toEqual(Option.some("secret"))
expect(config.port).toBe(4096)
}),
)
it.effect("defaultLayer applies Effect Config defaults", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(Effect.provide(fromConfig({ NAME: "kit" })))
expect(config.name).toBe("kit")
expect(config.token).toEqual(Option.none())
expect(config.port).toBe(3000)
}),
)
it.effect("layer provides an already parsed service value", () =>
Effect.gen(function* () {
const config = yield* readConfig.pipe(
Effect.provide(
TestConfig.layer({
name: "direct",
token: Option.some("parsed"),
port: 9000,
}),
),
)
expect(config).toEqual({
name: "direct",
token: Option.some("parsed"),
port: 9000,
} satisfies Context.Service.Shape<typeof TestConfig>)
}),
)
})
+10 -2
View File
@@ -115,8 +115,16 @@ describe("Runner", () => {
Effect.gen(function* () {
const s = yield* Scope.Scope
const runner = Runner.make<string>(s)
const fiber = yield* runner.ensureRunning(Effect.never.pipe(Effect.as("never"))).pipe(Effect.forkChild)
yield* Effect.sleep("10 millis")
const started = yield* Deferred.make<void>()
const fiber = yield* runner
.ensureRunning(
Effect.gen(function* () {
yield* Deferred.succeed(started, void 0)
return yield* Effect.never.pipe(Effect.as("never"))
}),
)
.pipe(Effect.forkChild)
yield* Deferred.await(started)
expect(runner.busy).toBe(true)
expect(runner.state._tag).toBe("Running")
+15
View File
@@ -0,0 +1,15 @@
# Server Test Guide
Use these patterns for server and HttpApi middleware tests in this directory.
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers.
- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests.
- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state.
- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server.
@@ -0,0 +1,103 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import {
Authorization,
ServerAuthConfig,
authorizationLayer,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { testEffect } from "../lib/effect"
const Api = HttpApi.make("test-authorization").add(
HttpApiGroup.make("test")
.add(
HttpApiEndpoint.get("probe", "/probe", {
success: Schema.String,
}),
)
.middleware(Authorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
const basic = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
const getProbe = (headers?: Record<string, string>) =>
HttpClientRequest.get("/probe").pipe(
headers ? HttpClientRequest.setHeaders(headers) : (request) => request,
HttpClient.execute,
)
describe("HttpApi authorization middleware", () => {
it.live("allows requests when server password is not configured", () =>
Effect.gen(function* () {
const response = yield* getProbe()
expect(response.status).toBe(200)
expect(yield* response.json).toBe("ok")
}),
)
itSecret.live("requires configured password for basic auth", () =>
Effect.gen(function* () {
const [missing, badPassword, good] = yield* Effect.all(
[
getProbe(),
getProbe({ authorization: basic("opencode", "wrong") }),
getProbe({ authorization: basic("opencode", "secret") }),
],
{ concurrency: "unbounded" },
)
expect(missing.status).toBe(401)
expect(badPassword.status).toBe(401)
expect(good.status).toBe(200)
}),
)
itKitSecret.live("respects configured basic auth username", () =>
Effect.gen(function* () {
const [defaultUser, configuredUser] = yield* Effect.all(
[getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })],
{ concurrency: "unbounded" },
)
expect(defaultUser.status).toBe(401)
expect(configuredUser.status).toBe(200)
}),
)
itSecret.live("accepts auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
expect(response.status).toBe(200)
}),
)
itSecret.live("rejects malformed auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
expect(response.status).toBe(401)
}),
)
})
@@ -2,11 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
@@ -30,7 +33,26 @@ function app(input?: { password?: string; username?: string }) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
return Server.Default().app
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
@@ -94,9 +116,9 @@ type RequestBody = {
required?: boolean
}
function parameterKey(param: unknown) {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return
if (typeof param.in !== "string" || typeof param.name !== "string") return
function parameterKey(param: unknown): string | undefined {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
}
@@ -105,27 +127,29 @@ function parameterSchema(input: {
path: string
method: (typeof methods)[number]
name: string
}) {
}): unknown {
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
)
if (!param || typeof param !== "object" || !("schema" in param)) return
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
return param.schema
}
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
if (!body || typeof body !== "object" || !("content" in body)) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
const requestBody = body as RequestBody
return JSON.stringify({
required: requestBody.required === true,
content: Object.entries(requestBody.content ?? {})
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
.sort(),
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
.sort(([left], [right]) => left.localeCompare(right)),
})
}
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
if (!schema) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
const resolved = (
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
) as OpenApiSchema | undefined
@@ -142,6 +166,7 @@ function responseContentTypes(input: {
}) {
const responses = input.spec.paths[input.path]?.[input.method]?.responses
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
const response = (responses as Record<string, unknown>)[input.status]
if (!response || typeof response !== "object" || !("content" in response)) return []
const content = (response as { content?: unknown }).content
@@ -0,0 +1,167 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
yield* Effect.promise(() => resetDatabase())
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await Instance.disposeAll()
await resetDatabase()
}),
)
}),
)
const it = testEffect(
Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, Project.defaultLayer),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
name: "Local Test",
description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }),
create: async () => {
await mkdir(directory, { recursive: true })
},
async remove() {},
target: () => ({ type: "local" as const, directory }),
})
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
Effect.acquireRelease(
Effect.promise(async () => {
registerAdaptor(input.projectID, input.type, localAdaptor(input.directory))
return Workspace.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
})
}),
(workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
)
const probeInstanceContext = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return yield* HttpServerResponse.json({
directory: instance?.directory,
worktree: instance?.worktree,
projectID: instance?.project.id,
workspaceID,
})
})
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
Layer.provide(instanceContextTestLayer),
HttpRouter.serve,
Layer.build,
)
describe("HttpApi instance context middleware", () => {
it.live("provides instance context from the routed directory", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
yield* serveProbe()
const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
directory: dir,
worktree: dir,
projectID: project.project.id,
})
}),
)
it.live("falls back to the raw directory when URI decoding fails", () =>
Effect.gen(function* () {
yield* serveProbe()
const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A")
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({
directory: path.join(process.cwd(), "%E0%A4%A"),
})
}),
)
it.live("provides selected workspace id on control-plane routes", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "instance-context-workspace-ref",
directory: workspaceDir,
})
yield* serveProbe("/session")
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
HttpClient.execute,
)
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({
directory: dir,
workspaceID: workspace.id,
})
}),
)
it.live("uses workspace routing output instead of raw directory hints", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "instance-context-routing-output",
directory: workspaceDir,
})
yield* serveProbe()
const response = yield* HttpClientRequest.get(`/probe?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
HttpClient.execute,
)
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({
directory: workspaceDir,
workspaceID: workspace.id,
})
}),
)
})
@@ -1,9 +1,11 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { ConfigProvider, Effect, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Instance } from "../../src/project/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
@@ -33,7 +35,27 @@ function app(backend: Backend, input?: { password?: string; username?: string })
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
if (backend === "legacy") return Server.Legacy().app
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function client(
@@ -123,7 +145,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator<unknown> }>) {
}
function record(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
}
function array(value: unknown) {
@@ -0,0 +1,437 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue } from "effect"
import {
HttpClient,
HttpClientRequest,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import Http from "node:http"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project"
import {
WorkspaceRouteContext,
workspaceRouterMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { Database } from "../../src/storage/db"
import { resetDatabase } from "../fixture/db"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
yield* Effect.promise(() => resetDatabase())
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await resetDatabase()
}),
)
}),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
Project.defaultLayer,
Socket.layerWebSocketConstructorGlobal,
),
)
type ProxiedRequest = {
url: string
method: string
headers: Record<string, string>
}
type TestHandler<E, R> = (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
)
const serverUrl = HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
const requestURL = (request: { readonly url: string }) => new URL(request.url, "http://localhost")
const listenAdditionalServer = <E, R>(handler: TestHandler<E, R>) =>
Effect.gen(function* () {
const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
const server = Context.get(context, HttpServer.HttpServer)
yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler))
return HttpServer.formatAddress(server.address)
})
const localAdaptor = (directory: string): WorkspaceAdaptor => ({
name: "Local Test",
description: "Create a local test workspace",
configure: (info) => ({ ...info, name: "local-test", directory }),
create: async () => {
await mkdir(directory, { recursive: true })
},
async remove() {},
target: () => ({ type: "local" as const, directory }),
})
const remoteAdaptor = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor => ({
name: "Remote Test",
description: "Create a remote test workspace",
configure: (info) => ({ ...info, name: "remote-test", directory }),
create: async () => {
await mkdir(directory, { recursive: true })
},
async remove() {},
target: () => ({ type: "remote" as const, url, headers }),
})
const eventStreamResponse = () =>
HttpServerResponse.text('data: {"payload":{"type":"server.connected","properties":{}}}\n\n', {
contentType: "text/event-stream",
})
const syncResponse = (request: HttpServerRequest.HttpServerRequest) => {
const url = requestURL(request)
if (url.pathname === "/base/global/event") return Effect.succeed(eventStreamResponse())
if (url.pathname === "/base/sync/history") return HttpServerResponse.json([])
return undefined
}
const createWorkspace = (input: { projectID: Project.Info["id"]; type: string; adaptor: WorkspaceAdaptor }) =>
Effect.acquireRelease(
Effect.promise(async () => {
registerAdaptor(input.projectID, input.type, input.adaptor)
return Workspace.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
})
}),
(workspace) => Effect.promise(() => Workspace.remove(workspace.id)).pipe(Effect.ignore),
)
const createRemoteWorkspace = (input: {
dir: string
projectID: Project.Info["id"]
type: string
url: string
headers?: HeadersInit
}) =>
// Workspace.create starts the remote sync loop. The test upstream exposes
// /global/event and /sync/history so middleware proxying sees the remote
// workspace as active, just like production would.
createWorkspace({
projectID: input.projectID,
type: input.type,
adaptor: remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url, input.headers),
})
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
createWorkspace({
projectID: input.projectID,
type: input.type,
adaptor: localAdaptor(input.directory),
})
const insertRemoteWorkspaceWithoutSync = (input: {
dir: string
projectID: Project.Info["id"]
type: string
url: string
}) =>
Effect.sync(() => {
const id = WorkspaceID.ascending()
registerAdaptor(input.projectID, input.type, remoteAdaptor(path.join(input.dir, `.${input.type}`), input.url))
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
return id
})
const startRemoteWorkspaceHttpServer = <E, R>(
handler: (request: ProxiedRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
) =>
listenAdditionalServer((request) =>
Effect.gen(function* () {
// Remote workspaces run a sync loop against their target server. These
// bootstrap routes make Workspace.isSyncing(...) true for proxy tests;
// everything else is the request being proxied by the middleware.
const sync = syncResponse(request)
if (sync) return yield* sync
return yield* handler({ url: request.url, method: request.method, headers: request.headers })
}),
)
const listenRemoteWebSocket = () =>
listenAdditionalServer((request) => {
const sync = syncResponse(request)
if (sync) return sync
if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return echoWebSocket(request)
})
const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
Effect.gen(function* () {
const socket = yield* Effect.orDie(request.upgrade)
const write = yield* socket.writer
yield* socket
.runRaw((message) => write(`echo:${String(message)}`), {
onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
Effect.catch(() => Effect.void),
),
})
.pipe(Effect.catch(() => Effect.void))
return HttpServerResponse.empty()
})
const serveRouteContextProbe = HttpRouter.add(
"GET",
"/probe",
Effect.gen(function* () {
// The fake route exposes the context installed by the middleware, so tests
// can assert routing decisions without pulling in the production API tree.
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
describe("HttpApi workspace routing middleware", () => {
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
let forwarded: ProxiedRequest | undefined
// This starts a second HTTP server that stands in for the opencode server
// backing a remote workspace. The client below still calls the local test
// server; only the middleware should call this server.
const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => {
forwarded = request
const url = requestURL(request)
return HttpServerResponse.json(
{
proxied: true,
path: url.pathname,
keep: url.searchParams.get("keep"),
workspace: url.searchParams.get("workspace"),
},
{ status: 201, headers: { "x-remote": "yes" } },
)
})
// The adaptor target tells the middleware where to proxy selected remote
// workspace requests. Appending /probe to this base should produce
// `${remoteUrl}/base/probe` on the fake remote server above.
const workspace = yield* createRemoteWorkspace({
dir,
projectID: project.project.id,
type: "remote-http-target",
url: `${remoteUrl}/base`,
headers: { "x-target-auth": "secret" },
})
// The local /probe handler should not run. Selecting a remote workspace
// should make the middleware call HttpApiProxy.http instead.
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
HttpClientRequest.setHeaders({
"content-type": "application/json",
"x-opencode-directory": "/secret/path",
"x-opencode-workspace": "internal",
}),
HttpClient.execute,
)
expect(response.status).toBe(201)
expect(response.headers["x-remote"]).toBe("yes")
expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null })
const forwardedURL = forwarded ? requestURL(forwarded) : undefined
// These assertions are the routing contract: append the original path to
// the remote base URL, preserve normal query params, and remove workspace.
expect(forwardedURL?.pathname).toBe("/base/probe")
expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
expect(forwarded?.method).toBe("PATCH")
expect(forwarded?.headers["content-type"]).toBe("application/json")
expect(forwarded?.headers["x-target-auth"]).toBe("secret")
expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined()
expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined()
}),
)
it.live("returns 503 when a remote workspace is not actively syncing", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceID = yield* insertRemoteWorkspaceWithoutSync({
dir,
projectID: project.project.id,
type: "remote-not-syncing",
url: "http://127.0.0.1:1/base",
})
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
expect(response.status).toBe(503)
expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`)
}),
)
it.live("proxies remote workspace WebSocket requests through the selected workspace target", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const remoteUrl = yield* listenRemoteWebSocket()
const workspace = yield* createRemoteWorkspace({
dir,
projectID: project.project.id,
type: "remote-websocket-target",
url: `${remoteUrl}/base`,
})
// The client connects to the local test server. The middleware should
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
{
closeCodeIsError: () => false,
protocols: "chat",
},
)
const messages = yield* Queue.unbounded<string>()
yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped)
const write = yield* socket.writer
expect(yield* Queue.take(messages)).toBe("protocol:chat")
yield* write("hello")
expect(yield* Queue.take(messages)).toBe("echo:hello")
}),
)
it.live("returns a missing workspace response for unknown workspace ids", () =>
Effect.gen(function* () {
const workspaceID = WorkspaceID.ascending("wrk_missing")
// If the middleware resolves the workspace first, this handler is never
// reached and the response should be the middleware error response.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
expect(response.status).toBe(500)
expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`)
}),
)
it.live("keeps control-plane routes local even when workspace is selected", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "control-plane-target",
directory: workspaceDir,
})
// GET /session is a control-plane route: it lists sessions for the main
// process and should not be redirected into the selected workspace target.
yield* HttpRouter.add(
"GET",
"/session",
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
}),
)
it.live("uses directory query/header fallback when no workspace is selected", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const queryDir = path.join(dir, "query-target")
const headerDir = path.join(dir, "header-target")
yield* serveRouteContextProbe
// Without a selected workspace, the middleware falls back to request
// directory hints before using the process cwd.
const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`)
const headerResponse = yield* HttpClientRequest.get("/probe").pipe(
HttpClientRequest.setHeader("x-opencode-directory", headerDir),
HttpClient.execute,
)
expect(queryResponse.status).toBe(200)
expect(yield* queryResponse.json).toEqual({ directory: queryDir })
expect(headerResponse.status).toBe(200)
expect(yield* headerResponse.json).toEqual({ directory: headerDir })
}),
)
it.live("routes local workspace requests through WorkspaceRouteContext", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "local-target",
directory: workspaceDir,
})
yield* serveRouteContextProbe
// /probe is not a control-plane route, so selecting a local workspace
// should swap the route context to the workspace target directory.
const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
directory: workspaceDir,
workspaceID: workspace.id,
})
}),
)
})
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import path from "path"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
type SyncTrace = { type: string; directory?: string }
function app() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
return Server.Default().app
}
function route(pathname: string, directory: string, query?: Record<string, string>) {
const url = new URL(pathname, "http://localhost")
url.searchParams.set("directory", directory)
for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, value)
}
return url
}
async function fetchJson<T>(
pathname: string,
directory: string,
init?: RequestInit,
query?: Record<string, string>,
) {
const response = await app().fetch(new Request(route(pathname, directory, query), init))
if (response.status !== 200) throw new Error(await response.text())
return (await response.json()) as T
}
function pathFor(pathname: string, params: Record<string, string>) {
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), pathname)
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
await Instance.disposeAll()
await resetDatabase()
})
describe("Hono session routes", () => {
test("use request directory for non-session routes and saved session directory for session routes", async () => {
await using sessionDir = await tmpdir({
git: true,
config: { formatter: false, lsp: false },
init: (dir) => Bun.write(path.join(dir, "marker.txt"), "session-directory"),
})
await using requestDir = await tmpdir({
git: true,
config: { formatter: false, lsp: false },
init: (dir) => Bun.write(path.join(dir, "marker.txt"), "request-directory"),
})
const json = { "content-type": "application/json" }
const trace: SyncTrace[] = []
const onEvent = (event: GlobalEvent) => {
if (event.payload.type !== "sync") return
if (!["session.created.1", "message.updated.1", "message.part.updated.1"].includes(event.payload.syncEvent.type)) return
trace.push({ type: event.payload.syncEvent.type, directory: event.directory })
}
GlobalBus.on("event", onEvent)
const session = await fetchJson<{ id: string }>("/session", sessionDir.path, {
method: "POST",
headers: json,
body: JSON.stringify({ title: "session-dir" }),
})
const currentPath = await fetchJson<{ directory: string }>("/path", requestDir.path)
expect(currentPath.directory).toBe(requestDir.path)
const marker = await fetchJson<{ type: string; content: string }>(
"/file/content",
requestDir.path,
undefined,
{
path: "marker.txt",
},
)
expect(marker).toMatchObject({ type: "text", content: "request-directory" })
await fetchJson<unknown>(pathFor("/session/:sessionID", { sessionID: session.id }), requestDir.path)
await fetchJson<unknown>(
pathFor("/session/:sessionID/fork", { sessionID: session.id }),
requestDir.path,
{
method: "POST",
headers: json,
body: JSON.stringify({}),
},
)
await fetchJson<{ info: { path: { cwd: string; root: string } }; parts: unknown[] }>(
pathFor("/session/:sessionID/shell", { sessionID: session.id }),
requestDir.path,
{
method: "POST",
headers: json,
body: JSON.stringify({
agent: "build",
model: { providerID: "test", modelID: "test" },
command: "pwd",
}),
},
)
GlobalBus.off("event", onEvent)
expect(trace).toContainEqual({ type: "session.created.1", directory: sessionDir.path })
expect(trace.filter((event) => event.type === "session.created.1")).toEqual([
{ type: "session.created.1", directory: sessionDir.path },
{ type: "session.created.1", directory: sessionDir.path },
])
expect(trace.filter((event) => event.type === "message.updated.1").map((event) => event.directory)).toEqual(
expect.arrayContaining([sessionDir.path]),
)
expect(trace.filter((event) => event.type === "message.updated.1").every((event) => event.directory === sessionDir.path))
.toBe(true)
})
})
@@ -46,7 +46,7 @@ function echoWebSocket(request: HttpServerRequest.HttpServerRequest) {
// The upstream announces the negotiated protocol, then echoes every
// received frame. The assertions use those messages to prove proxy flow.
yield* socket
.runRaw((message) => write(`echo:${message}`), {
.runRaw((message) => write(`echo:${String(message)}`), {
onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
Effect.catch(() => Effect.void),
),