feat(aircoding): AirCoding V2 baseline — deterministic multi-agent architecture

Forked from OpenCode v1.17.4 with multi-agent system:
- 5 agents: aircoding, scheduler, worker, architect, reviewer
- Deterministic DAG scheduling engine (coordinator_tick)
- Tool whitelists as hard enforcement
- AirCoding validation plugin
- V1 requirements: C4 docs, ADR, AGENTS.md, debug-log.md
- Design documents in docs/
This commit is contained in:
airlongdian
2026-06-13 21:53:43 +08:00
commit deaf7eb806
5757 changed files with 1169969 additions and 0 deletions

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 tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`.
- 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.

View File

@@ -0,0 +1,59 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Option, Redacted } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "../../src/server/auth"
const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
afterEach(() => {
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
})
describe("ServerAuth", () => {
test("does not emit auth headers without a password", () => {
Flag.OPENCODE_SERVER_PASSWORD = undefined
Flag.OPENCODE_SERVER_USERNAME = "alice"
expect(ServerAuth.header()).toBeUndefined()
expect(ServerAuth.headers()).toBeUndefined()
})
test("defaults to the opencode username", () => {
Flag.OPENCODE_SERVER_PASSWORD = "secret"
Flag.OPENCODE_SERVER_USERNAME = undefined
expect(ServerAuth.headers()).toEqual({
Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
})
})
test("uses the configured username", () => {
Flag.OPENCODE_SERVER_PASSWORD = "secret"
Flag.OPENCODE_SERVER_USERNAME = "alice"
expect(ServerAuth.headers()).toEqual({
Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`,
})
})
test("prefers explicit credentials", () => {
Flag.OPENCODE_SERVER_PASSWORD = "secret"
Flag.OPENCODE_SERVER_USERNAME = "alice"
expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
})
})
test("validates decoded credentials against effect config", () => {
const config = { password: Option.some("secret"), username: "alice" }
expect(ServerAuth.required(config)).toBe(true)
expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true)
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false)
})
})

View File

@@ -0,0 +1,31 @@
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Cause, Effect } from "effect"
export function waitGlobalBusEvent(input: {
timeout?: number
message?: string
predicate: (event: GlobalEvent) => boolean
}) {
return Effect.callback<GlobalEvent, unknown>((resume) => {
const cleanup = () => GlobalBus.off("event", handler)
const handler = (event: GlobalEvent) => {
try {
if (!input.predicate(event)) return
cleanup()
resume(Effect.succeed(event))
} catch (error) {
cleanup()
resume(Effect.fail(error))
}
}
GlobalBus.on("event", handler)
return Effect.sync(cleanup)
}).pipe(
Effect.timeout(input.timeout ?? 10_000),
Effect.mapError((error) =>
Cause.isTimeoutError(error) ? new Error(input.message ?? "timed out waiting for global bus event") : error,
),
)
}

View File

@@ -0,0 +1,104 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer } from "effect"
import { Project } from "@/project/project"
import { Session as SessionNs } from "@/session/session"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Project.defaultLayer, CrossSpawnSpawner.defaultLayer))
const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
Effect.acquireRelease(SessionNs.use.create(input), (created) =>
SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)),
)
describe("session.listGlobal", () => {
it.instance(
"lists sessions across projects with project metadata",
() =>
Effect.gen(function* () {
const first = yield* TestInstance
const second = yield* tmpdirScoped({ git: true })
const firstSession = yield* withSession({ title: "first-session" })
const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second))
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
const ids = sessions.map((session) => session.id)
expect(ids).toContain(firstSession.id)
expect(ids).toContain(secondSession.id)
const firstProject = yield* Project.use.get(firstSession.projectID)
const secondProject = yield* Project.use.get(secondSession.projectID)
const firstItem = sessions.find((session) => session.id === firstSession.id)
const secondItem = sessions.find((session) => session.id === secondSession.id)
expect(firstItem?.project?.id).toBe(firstProject?.id)
expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
expect(secondItem?.project?.id).toBe(secondProject?.id)
expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
expect(first.directory).not.toBe(second)
}),
{ git: true },
)
it.instance(
"excludes archived sessions by default",
() =>
Effect.gen(function* () {
const archived = yield* withSession({ title: "archived-session" })
yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() }))
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
const ids = sessions.map((session) => session.id)
expect(ids).not.toContain(archived.id)
const allSessions = yield* SessionNs.Service.use((session) =>
session.listGlobal({ limit: 200, archived: true }),
)
const allIds = allSessions.map((session) => session.id)
expect(allIds).toContain(archived.id)
}),
{ git: true },
)
it.instance(
"supports cursor pagination",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const first = yield* withSession({ title: "page-one" })
const ready = yield* Deferred.make<void>()
yield* Deferred.succeed(ready, undefined).pipe(Effect.delay("5 millis"), Effect.forkScoped)
yield* Deferred.await(ready).pipe(
Effect.timeoutOrElse({
duration: "1 second",
orElse: () => Effect.fail(new Error("timed out waiting between session creates")),
}),
)
const second = yield* withSession({ title: "page-two" })
const page = yield* SessionNs.Service.use((session) =>
session.listGlobal({ directory: test.directory, limit: 1 }),
)
expect(page.length).toBe(1)
expect(page[0].id).toBe(second.id)
const next = yield* SessionNs.Service.use((session) =>
session.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }),
)
const ids = next.map((session) => session.id)
expect(ids).toContain(first.id)
expect(ids).not.toContain(second.id)
}),
{ git: true },
)
})

View File

@@ -0,0 +1,174 @@
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, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
import { ServerAuth } from "../../src/server/auth"
import {
Authorization,
authorizationLayer,
ServerAuthorization,
serverAuthorizationLayer,
} 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,
}),
HttpApiEndpoint.get("missing", "/missing", {
success: Schema.String,
error: HttpApiError.NotFound,
}),
)
.middleware(Authorization),
)
const ServerApi = HttpApi.make("test-server-authorization").add(
HttpApiGroup.make("test.v2")
.add(
HttpApiEndpoint.get("probe", "/api/probe", {
success: Schema.String,
}),
)
.middleware(ServerAuthorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
handlers
.handle("probe", () => Effect.succeed("ok"))
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
)
const serverHandlers = HttpApiBuilder.group(ServerApi, "test.v2", (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 v2ApiLayer = HttpRouter.serve(
HttpApiBuilder.layer(ServerApi).pipe(Layer.provide(serverHandlers), Layer.provide(serverAuthorizationLayer)),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
const kitSecretLayer = ServerAuth.Config.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 itV2Secret = testEffect(v2ApiLayer.pipe(Layer.provide(secretLayer)))
const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
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(missing.headers["www-authenticate"] ?? "").toContain("Basic")
expect(badPassword.status).toBe(401)
expect(badPassword.headers["www-authenticate"] ?? "").toContain("Basic")
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("prefers auth token query credentials over basic auth", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get(
`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`,
).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute)
expect(response.status).toBe(200)
}),
)
itSecret.live("preserves handler errors when basic auth succeeds", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get("/missing").pipe(
HttpClientRequest.setHeader("authorization", basic("opencode", "secret")),
HttpClient.execute,
)
expect(response.status).toBe(404)
}),
)
itSecret.live("preserves handler errors when auth token query succeeds", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
expect(response.status).toBe(404)
}),
)
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)
}),
)
itV2Secret.live("returns bodyful v2 unauthorized errors", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get("/api/probe")
const body = yield* response.json
expect(response.status).toBe(401)
expect(response.headers["www-authenticate"] ?? "").toContain("Basic")
expect(body).toEqual({ _tag: "UnauthorizedError", message: "Authentication required" })
}),
)
})

View File

@@ -0,0 +1,151 @@
import { afterEach, describe, expect, test } from "bun:test"
import { gunzipSync, inflateSync } from "node:zlib"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function app() {
return Server.Default().app
}
// /config echoes the config back. Padding the config pushes the response body
// well past the 1024 B threshold so we can observe compression behavior.
function fatConfig() {
const instructions: string[] = []
for (let i = 0; i < 50; i++) {
instructions.push(`padding-instruction-${i}-${"x".repeat(40)}`)
}
return {
formatter: false,
lsp: false,
username: "compression-test-user",
instructions,
}
}
describe("HttpApi compression", () => {
describe("encodes responses", () => {
test("gzips JSON when Accept-Encoding includes gzip and body exceeds threshold", async () => {
await using tmp = await tmpdir({ config: fatConfig() })
const response = await app().request("/config", {
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
})
expect(response.status).toBe(200)
expect(response.headers.get("content-encoding")).toBe("gzip")
const compressed = new Uint8Array(await response.arrayBuffer())
const decompressed = gunzipSync(compressed)
const json = JSON.parse(new TextDecoder().decode(decompressed))
expect(json).toMatchObject({ username: "compression-test-user" })
expect(compressed.byteLength).toBeLessThan(decompressed.byteLength)
})
test("uses deflate when only deflate is acceptable", async () => {
await using tmp = await tmpdir({ config: fatConfig() })
const response = await app().request("/config", {
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "deflate" },
})
expect(response.status).toBe(200)
expect(response.headers.get("content-encoding")).toBe("deflate")
const compressed = new Uint8Array(await response.arrayBuffer())
const decompressed = inflateSync(compressed)
const json = JSON.parse(new TextDecoder().decode(decompressed))
expect(json).toMatchObject({ username: "compression-test-user" })
})
test("prefers gzip when both gzip and deflate are acceptable", async () => {
await using tmp = await tmpdir({ config: fatConfig() })
const response = await app().request("/config", {
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip, deflate" },
})
expect(response.headers.get("content-encoding")).toBe("gzip")
})
test("does not include the original Content-Length when compressed", async () => {
await using tmp = await tmpdir({ config: fatConfig() })
const response = await app().request("/config", {
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
})
const compressed = new Uint8Array(await response.arrayBuffer())
const declared = response.headers.get("content-length")
// Either absent (transfer-encoding chunked) or matches the compressed length.
if (declared !== null) expect(Number(declared)).toBe(compressed.byteLength)
})
})
describe("skips", () => {
test("when no Accept-Encoding header is present", async () => {
await using tmp = await tmpdir({ config: fatConfig() })
const response = await app().request("/config", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.headers.get("content-encoding")).toBeNull()
})
test("when Accept-Encoding only allows unsupported encodings", async () => {
await using tmp = await tmpdir({ config: fatConfig() })
const response = await app().request("/config", {
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "br" },
})
expect(response.headers.get("content-encoding")).toBeNull()
})
test("when the response body is below the 1024-byte threshold", async () => {
// A bare config produces a tiny response (~few hundred bytes).
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const response = await app().request("/config", {
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
})
expect(response.status).toBe(200)
const body = new Uint8Array(await response.arrayBuffer())
expect(body.byteLength).toBeLessThan(1024)
expect(response.headers.get("content-encoding")).toBeNull()
})
test("HEAD requests", async () => {
await using tmp = await tmpdir({ config: fatConfig() })
const response = await app().request("/config", {
method: "HEAD",
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
})
expect(response.headers.get("content-encoding")).toBeNull()
})
})
describe("streaming exclusions", () => {
test("/event SSE is not compressed", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const controller = new AbortController()
const response = await app().request("/event", {
headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
signal: controller.signal,
})
try {
expect(response.status).toBe(200)
expect(response.headers.get("content-encoding")).toBeNull()
} finally {
controller.abort()
await response.body?.cancel().catch(() => {})
}
})
test("/global/event SSE is not compressed", async () => {
const controller = new AbortController()
const response = await app().request("/global/event", {
headers: { "accept-encoding": "gzip" },
signal: controller.signal,
})
try {
expect(response.status).toBe(200)
expect(response.headers.get("content-encoding")).toBeNull()
} finally {
controller.abort()
await response.body?.cancel().catch(() => {})
}
})
})
})

View File

@@ -0,0 +1,110 @@
import { afterEach, describe, expect } from "bun:test"
import path from "path"
import { Server } from "../../src/server/server"
import { Effect, Fiber } from "effect"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
import { waitGlobalBusEvent } from "./global-bus"
function app() {
return Server.Default().app
}
function waitDisposed(directory: string) {
return waitGlobalBusEvent({
message: "timed out waiting for instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed" && event.directory === directory,
})
}
const tmpdirEffect = (options: Parameters<typeof tmpdir>[0]) =>
Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("config HttpApi", () => {
it.live(
"serves config update through the default server app",
Effect.gen(function* () {
const tmp = yield* tmpdirEffect({ config: { formatter: false, lsp: false } })
const disposed = yield* waitDisposed(tmp.path).pipe(Effect.forkScoped({ startImmediately: true }))
const response = yield* Effect.promise(() =>
Promise.resolve(
app().request("/config", {
method: "PATCH",
headers: {
"content-type": "application/json",
"x-opencode-directory": tmp.path,
},
body: JSON.stringify({ username: "patched-user", formatter: false, lsp: false }),
}),
),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
username: "patched-user",
formatter: false,
lsp: false,
})
yield* Fiber.join(disposed)
expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "config.json")).json())).toMatchObject({
username: "patched-user",
formatter: false,
lsp: false,
})
}),
)
it.live(
"serves config with active provider model status",
Effect.gen(function* () {
const tmp = yield* tmpdirEffect({
config: {
formatter: false,
lsp: false,
provider: {
omniroute: {
models: {
"gpt-4o": {
status: "active",
},
},
},
},
},
})
const response = yield* Effect.promise(() =>
Promise.resolve(
app().request("/config", {
headers: {
"x-opencode-directory": tmp.path,
},
}),
),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
provider: {
omniroute: {
models: {
"gpt-4o": {
status: "active",
},
},
},
},
})
}),
)
})

View File

@@ -0,0 +1,63 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Option, Ref } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { Installation } from "../../src/installation"
import { ServerAuth } from "../../src/server/auth"
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
import { testEffect } from "../lib/effect"
const input = MoveSession.Input.make({
sessionID: SessionV2.ID.make("ses_move"),
destination: { directory: AbsolutePath.make("/destination") },
moveChanges: true,
})
const called = Ref.makeUnsafe<MoveSession.Input | undefined>(undefined)
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
// Raw HttpApi routes expose an opaque handler context at the request boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(Layer.mock(Auth.Service)({})),
Layer.provide(Layer.mock(Config.Service)({})),
Layer.provide(Layer.mock(Installation.Service)({})),
Layer.provide(
Layer.mock(MoveSession.Service)({
moveSession: (value) => Ref.set(called, value),
}),
),
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
)
const it = testEffect(apiLayer)
describe("control-plane HttpApi", () => {
it.live("moves a session through the root control-plane route", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.post("/experimental/control-plane/move-session").pipe(
HttpClientRequest.setBody(HttpBody.jsonUnsafe(input)),
HttpClient.execute,
)
expect(response.status).toBe(204)
expect(yield* Ref.get(called)).toEqual(input)
}),
)
})

View File

@@ -0,0 +1,63 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances } from "../fixture/fixture"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function app() {
return Server.Default().app
}
const PREFLIGHT_HEADERS = {
origin: "http://localhost:3000",
"access-control-request-method": "POST",
"access-control-request-headers": "content-type, x-opencode-directory",
}
// effect-smol's HttpMiddleware.cors overwrites `Vary: Origin` with
// `Vary: Access-Control-Request-Headers` on OPTIONS preflight responses
// (the two share the same record key during the spread). With dynamic
// origin echoing, missing Vary: Origin lets shared caches serve a preflight
// cached for one origin against a different origin. corsVaryFixLayer
// restores the merged form.
describe("CORS preflight Vary header", () => {
test("HTTP API backend preflight Vary contains Origin", async () => {
const response = await app().request("/global/config", {
method: "OPTIONS",
headers: PREFLIGHT_HEADERS,
})
expect([200, 204]).toContain(response.status)
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
expect((response.headers.get("vary") ?? "").toLowerCase()).toContain("origin")
})
test("HTTP API backend preflight Vary still preserves Access-Control-Request-Headers", async () => {
const response = await app().request("/global/config", {
method: "OPTIONS",
headers: PREFLIGHT_HEADERS,
})
const vary = (response.headers.get("vary") ?? "").toLowerCase()
expect(vary).toContain("origin")
expect(vary).toContain("access-control-request-headers")
})
test("HTTP API backend does not duplicate Origin in Vary", async () => {
const response = await app().request("/global/config", {
method: "OPTIONS",
headers: PREFLIGHT_HEADERS,
})
const vary = response.headers.get("vary") ?? ""
const originCount = vary
.split(",")
.map((s: string) => s.trim().toLowerCase())
.filter((s: string) => s === "origin").length
expect(originCount).toBe(1)
})
})

View File

@@ -0,0 +1,122 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Config, ConfigProvider, Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
}
Flag.OPENCODE_SERVER_PASSWORD = "secret"
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
await resetDatabase()
}),
)
}),
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{ disableListenLog: true, disableLogger: true },
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
),
),
)
describe("HttpApi CORS", () => {
it.live("allows browser preflight requests without credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.options(InstancePaths.path).pipe(
HttpClientRequest.setHeaders({
origin: "http://localhost:3000",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
}),
HttpClient.execute,
)
expect(response.status).toBe(204)
expect(response.headers["access-control-allow-origin"]).toBe("http://localhost:3000")
expect(response.headers["access-control-allow-headers"]).toBe("authorization")
}),
)
it.live("adds CORS headers to unauthorized responses", () =>
Effect.gen(function* () {
const handler = HttpRouter.toWebHandler(
HttpApiApp.createRoutes().pipe(
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: "secret" }))),
),
{ disableLogger: true },
).handler
const response = yield* Effect.promise(() =>
handler(
new Request(new URL("/global/config", "http://localhost"), {
headers: { origin: "https://app.opencode.ai" },
}),
HttpApiApp.context,
),
)
expect(response.status).toBe(401)
expect(response.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai")
}),
)
it.live("uses custom CORS origins passed to the server", () =>
Effect.gen(function* () {
const listener = yield* Effect.acquireRelease(
Effect.promise(() => Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] })),
(listener) => Effect.promise(() => listener.stop(true)),
)
const response = yield* Effect.promise(() =>
fetch(new URL(InstancePaths.path, listener.url), {
method: "OPTIONS",
headers: {
origin: "https://custom.example",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
},
}),
)
expect(response.status).toBe(204)
expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example")
expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
const rejected = yield* Effect.promise(() =>
fetch(new URL(InstancePaths.path, listener.url), {
method: "OPTIONS",
headers: {
origin: "https://evil.example",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization",
},
}),
)
expect(rejected.status).toBe(204)
expect(rejected.headers.get("access-control-allow-origin")).not.toBe("https://evil.example")
}),
)
})

View File

@@ -0,0 +1,101 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { NamedError } from "@opencode-ai/core/util/error"
import { describe, expect } from "bun:test"
import { ConfigErrorV1 } from "@opencode-ai/core/v1/config/error"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { errorLayer } from "../../src/server/routes/instance/httpapi/middleware/error"
import { NotFoundError } from "../../src/storage/storage"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(NodeHttpServer.layerTest, NodeServices.layer))
function expectUnknownErrorBody(body: unknown) {
expect(body).toMatchObject({
name: "UnknownError",
data: { message: "Unexpected server error. Check server logs for details." },
})
expect((body as { data?: { ref?: unknown } }).data?.ref).toMatch(/^err_[0-9a-f-]{8}$/)
}
describe("HttpApi error middleware", () => {
it.live("returns a safe body for unknown 500 defects", () =>
Effect.gen(function* () {
yield* HttpRouter.add("GET", "/boom", Effect.die(new Error("secret stack marker"))).pipe(
Layer.provide(errorLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClientRequest.get("/boom").pipe(HttpClient.execute)
const body = yield* response.json
expect(response.status).toBe(500)
expectUnknownErrorBody(body)
expect(JSON.stringify(body)).not.toContain("secret stack marker")
}),
)
it.live("returns a safe body for named defects", () =>
Effect.gen(function* () {
yield* HttpRouter.add(
"GET",
"/named",
Effect.die(new NamedError.Unknown({ message: "secret named marker" })),
).pipe(Layer.provide(errorLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClientRequest.get("/named").pipe(HttpClient.execute)
const body = yield* response.json
expect(response.status).toBe(500)
expectUnknownErrorBody(body)
expect(JSON.stringify(body)).not.toContain("secret named marker")
}),
)
it.live("returns invalid config defects as structured client errors", () =>
Effect.gen(function* () {
const configError = new ConfigErrorV1.InvalidError({
path: "/tmp/opencode.json",
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
})
yield* HttpRouter.add("GET", "/config-error", Effect.die(configError)).pipe(
Layer.provide(errorLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClientRequest.get("/config-error").pipe(HttpClient.execute)
const body = yield* response.json
const serialized = JSON.stringify(body)
expect(response.status).toBe(400)
expect(body).toMatchObject({
name: "ConfigInvalidError",
data: {
path: "/tmp/opencode.json",
issues: [{ message: "Expected object", path: ["provider", "anthropic", "options"] }],
},
})
expect(serialized).toContain("/tmp/opencode.json")
expect(serialized).toContain("anthropic")
}),
)
it.live("does not map storage not-found defects to 404", () =>
Effect.gen(function* () {
yield* HttpRouter.add(
"GET",
"/missing",
Effect.die(new NotFoundError({ message: "Resource not found: secret" })),
).pipe(Layer.provide(errorLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClientRequest.get("/missing").pipe(HttpClient.execute)
const body = yield* response.json
expect(response.status).toBe(500)
expectUnknownErrorBody(body)
}),
)
})

View File

@@ -0,0 +1,94 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer, Queue, Schema, Stream } from "effect"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const EventData = Schema.Struct({
id: Schema.optional(Schema.String),
type: Schema.String,
properties: Schema.Record(Schema.String, Schema.Any),
})
const readEvent = (reader: Queue.Dequeue<Uint8Array>) =>
Effect.gen(function* () {
const value = yield* Queue.take(reader).pipe(
Effect.timeoutOrElse({
duration: "5 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for event")),
}),
)
return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(value).replace(/^data: /, "")))
})
const openEventStream = (directory: string) =>
Effect.gen(function* () {
const response = yield* requestInDirectory(EventPaths.event, directory)
const reader = yield* Queue.unbounded<Uint8Array>()
yield* response.stream.pipe(
Stream.runForEach((value) => Queue.offer(reader, value)),
Effect.forkScoped,
)
return { response, reader }
})
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const it = testEffect(httpApiLayer)
describe("event HttpApi", () => {
it.instance(
"serves event stream",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const { response, reader } = yield* openEventStream(directory)
expect(response.status).toBe(200)
expect(response.headers["content-type"]).toContain("text/event-stream")
expect(response.headers["cache-control"]).toBe("no-cache, no-transform")
expect(response.headers["x-accel-buffering"]).toBe("no")
expect(response.headers["x-content-type-options"]).toBe("nosniff")
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"keeps the event stream open after the initial event",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const { reader } = yield* openEventStream(directory)
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
// If no second event arrives within 250ms, the stream is still open.
const status = yield* Queue.take(reader).pipe(
Effect.as("event" as const),
Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }),
)
expect(status).toBe("open")
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"delivers instance events after the initial event",
() =>
Effect.gen(function* () {
const { directory } = yield* TestInstance
const { reader } = yield* openEventStream(directory)
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
const created = yield* requestInDirectory("/session", directory, { method: "POST" })
expect(created.status).toBe(200)
expect(yield* readEvent(reader)).toMatchObject({ type: "session.created" })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})

View File

@@ -0,0 +1,64 @@
import type { CallResult, JsonObject } from "./types"
export function parse(text: string): unknown {
if (!text) return undefined
try {
return JSON.parse(text) as unknown
} catch {
return text
}
}
export function looksJson(result: CallResult) {
return result.contentType.includes("application/json") || result.text.startsWith("{") || result.text.startsWith("[")
}
export function stable(value: unknown): string {
return JSON.stringify(sort(value))
}
function sort(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sort)
if (!value || typeof value !== "object") return value
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, sort(item)]),
)
}
export function array(value: unknown): asserts value is unknown[] {
if (!Array.isArray(value)) throw new Error("expected array")
}
export function object(value: unknown): asserts value is JsonObject {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("expected object")
}
export function boolean(value: unknown): asserts value is boolean {
if (typeof value !== "boolean") throw new Error("expected boolean")
}
export function isRecord(value: unknown): value is JsonObject {
return !!value && typeof value === "object" && !Array.isArray(value)
}
export function check(value: boolean, message: string): asserts value {
if (!value) throw new Error(message)
}
export function message(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
export function pad(value: string, size: number) {
return value.length >= size ? value : value + " ".repeat(size - value.length)
}
export function indent(value: string) {
return value
.split("\n")
.map((line) => ` ${line}`)
.join("\n")
}

View File

@@ -0,0 +1,144 @@
import { ConfigProvider, Effect, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { parse } from "./assertions"
import { runtime, type Runtime } from "./runtime"
import type { ActiveScenario, BackendApp, CallResult, CaptureMode, SeededContext } from "./types"
type CallOptions = {
auth?: {
password?: string
username?: string
}
}
export function call(scenario: ActiveScenario, ctx: SeededContext<unknown>, options: CallOptions = {}) {
return Effect.promise(async () =>
capture(await app(await runtime(), options).request(toRequest(scenario, ctx)), scenario.capture),
)
}
export function callAuthProbe(scenario: ActiveScenario, credentials: "missing" | "valid" = "missing") {
return Effect.promise(async () => {
const controller = new AbortController()
return Promise.race([
Promise.resolve(
app(await runtime(), { auth: { password: "secret" } }).request(
toAuthProbeRequest(scenario, credentials, controller.signal),
),
).then((response) => capture(response, scenario.capture)),
Bun.sleep(1_000).then(() => {
controller.abort("auth probe timed out")
return {
status: 0,
contentType: "",
text: "auth probe timed out",
body: undefined,
timedOut: true,
}
}),
])
})
}
type CachedApp = BackendApp & { readonly dispose: () => Promise<void> }
const appCache: Partial<Record<string, CachedApp>> = {}
export async function disposeApps() {
const apps = Object.values(appCache)
for (const key of Object.keys(appCache)) delete appCache[key]
await Promise.all(apps.flatMap((app) => (app === undefined ? [] : [app.dispose()])))
}
function app(modules: Runtime, options: CallOptions) {
const username = options.auth?.username
const password = options.auth?.password
const cacheKey = `${username ?? ""}:${password ?? ""}`
if (appCache[cacheKey]) return appCache[cacheKey]
const web = HttpRouter.toWebHandler(
modules.HttpApiApp.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({ OPENCODE_SERVER_PASSWORD: password, OPENCODE_SERVER_USERNAME: username }),
),
),
),
{ disableLogger: true, memoMap: modules.memoMap },
)
return (appCache[cacheKey] = {
dispose: web.dispose,
request(input: string | URL | Request, init?: RequestInit) {
return web.handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
modules.HttpApiApp.context,
)
},
})
}
function toRequest(scenario: ActiveScenario, ctx: SeededContext<unknown>) {
const spec = scenario.request(ctx, ctx.state)
return new Request(new URL(spec.path, "http://localhost"), {
method: scenario.method,
headers: spec.body === undefined ? spec.headers : { "content-type": "application/json", ...spec.headers },
body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
})
}
function toAuthProbeRequest(scenario: ActiveScenario, credentials: "missing" | "valid", signal: AbortSignal) {
const spec = scenario.authProbe ?? {
path: authProbePath(scenario.path),
body: scenario.method === "GET" ? undefined : {},
}
const headers = {
...(spec.body === undefined ? {} : { "content-type": "application/json" }),
...spec.headers,
...(credentials === "valid" ? { authorization: basic("opencode", "secret") } : {}),
}
return new Request(new URL(spec.path, "http://localhost"), {
method: scenario.method,
headers,
body: spec.body === undefined ? undefined : JSON.stringify(spec.body),
signal,
})
}
function basic(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
function authProbePath(path: string) {
return path
.replace(/\{([^}]+)\}/g, (_match, key: string) => `auth_${key}`)
.replace(/:([^/]+)/g, (_match, key: string) => `auth_${key}`)
}
async function capture(response: Response, mode: CaptureMode): Promise<CallResult> {
const text = mode === "stream" ? await captureStream(response) : await response.text()
return {
status: response.status,
contentType: response.headers.get("content-type") ?? "",
text,
body: parse(text),
timedOut: false,
}
}
async function captureStream(response: Response) {
if (!response.body) return ""
const reader = response.body.getReader()
const read = reader.read().then(
(result) => ({ result }),
(error: unknown) => ({ error }),
)
const winner = await Promise.race([read, Bun.sleep(1_000).then(() => ({ timeout: true }))])
if ("timeout" in winner) {
await reader.cancel("timed out waiting for stream chunk").catch(() => undefined)
throw new Error("timed out waiting for stream chunk")
}
if ("error" in winner) throw winner.error
await reader.cancel().catch(() => undefined)
if (winner.result.done) return ""
return new TextDecoder().decode(winner.result.value)
}

View File

@@ -0,0 +1,210 @@
import { Effect } from "effect"
import { looksJson } from "./assertions"
import type {
ActiveScenario,
AuthPolicy,
BuilderState,
CallResult,
Comparison,
Method,
ProjectOptions,
RequestSpec,
ScenarioContext,
SeededContext,
TodoScenario,
} from "./types"
class ScenarioBuilder<S = undefined> {
private readonly state: BuilderState<S>
constructor(method: Method, path: string, name: string, auth: AuthPolicy) {
this.state = {
method,
path,
name,
project: { git: true },
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The unseeded builder state is intentionally undefined until `.seeded(...)` narrows it.
seed: () => Effect.succeed(undefined as S),
request: (ctx) => ({ path, headers: ctx.headers() }),
authProbe: undefined,
capture: "full",
mutates: false,
reset: true,
auth,
}
}
global() {
return this.clone({ project: undefined, request: () => ({ path: this.state.path }) })
}
inProject(project: ProjectOptions = { git: true }) {
return this.clone({ project })
}
withLlm() {
return this.clone({ project: { ...(this.state.project ?? { git: true }), llm: true } })
}
at(request: BuilderState<S>["request"]) {
return this.clone({ request })
}
probe(authProbe: RequestSpec) {
return this.clone({ authProbe })
}
mutating() {
return this.clone({ mutates: true })
}
preserveDatabase() {
return this.clone({ reset: false })
}
stream() {
return this.clone({ capture: "stream" })
}
protected() {
return this.auth("protected")
}
public() {
return this.auth("public")
}
publicBypass() {
return this.auth("public-bypass")
}
ticketBypass() {
return this.auth("ticket-bypass")
}
private auth(auth: AuthPolicy) {
return this.clone({ auth })
}
/** Assert a non-JSON or shape-only response. */
ok(status = 200, compare: Comparison = "status") {
return this.done(compare, (_ctx, result) =>
Effect.sync(() => {
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
}),
)
}
status(
status = 200,
inspect?: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
compare: Comparison = "status",
) {
return this.done(compare, (ctx, result) =>
Effect.gen(function* () {
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
if (inspect) yield* inspect(ctx, result)
}),
)
}
/** Assert JSON status/content-type plus an optional synchronous body check. */
json(status = 200, inspect?: (body: unknown, ctx: SeededContext<S>) => void, compare: Comparison = "json") {
return this.jsonEffect(status, inspect ? (body, ctx) => Effect.sync(() => inspect(body, ctx)) : undefined, compare)
}
/** Assert JSON status/content-type plus optional Effect assertions, e.g. DB side effects. */
jsonEffect(
status = 200,
inspect?: (body: unknown, ctx: SeededContext<S>) => Effect.Effect<void>,
compare: Comparison = "json",
) {
return this.done(compare, (ctx, result) =>
Effect.gen(function* () {
if (result.status !== status) throw new Error(`expected ${status}, got ${result.status}: ${result.text}`)
if (!looksJson(result))
throw new Error(`expected JSON response, got ${result.contentType || "no content-type"}`)
if (inspect) yield* inspect(result.body, ctx)
}),
)
}
private clone(next: Partial<BuilderState<S>>) {
const builder = new ScenarioBuilder<S>(this.state.method, this.state.path, this.state.name, this.state.auth)
Object.assign(builder.state, this.state, next)
return builder
}
/**
* Seed typed state before the HTTP request. The returned value becomes `ctx.state`
* for `.at(...)` and assertions, giving stateful route tests type-safe setup.
*/
seeded<Next>(seed: (ctx: ScenarioContext) => Effect.Effect<Next>) {
const builder = new ScenarioBuilder<Next>(this.state.method, this.state.path, this.state.name, this.state.auth)
Object.assign(builder.state, this.state, { seed })
return builder
}
private done(
compare: Comparison,
expect: (ctx: SeededContext<S>, result: CallResult) => Effect.Effect<void>,
): ActiveScenario {
const state = this.state
return {
kind: "active",
method: state.method,
path: state.path,
name: state.name,
project: state.project,
seed: state.seed,
authProbe: state.authProbe,
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired request/state type inside the builder.
request: (ctx, seeded) => state.request({ ...ctx, state: seeded as S }),
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `.seeded(...)` preserves the paired assertion/state type inside the builder.
expect: (ctx, seeded, result) => expect({ ...ctx, state: seeded as S }, result),
compare,
capture: state.capture,
mutates: state.mutates,
reset: state.reset,
auth: state.auth,
}
}
}
const routes = (auth: AuthPolicy) => ({
get: (path: string, name: string) => new ScenarioBuilder("GET", path, name, auth),
post: (path: string, name: string) => new ScenarioBuilder("POST", path, name, auth),
put: (path: string, name: string) => new ScenarioBuilder("PUT", path, name, auth),
patch: (path: string, name: string) => new ScenarioBuilder("PATCH", path, name, auth),
delete: (path: string, name: string) => new ScenarioBuilder("DELETE", path, name, auth),
})
export const http = {
protected: routes("protected"),
public: routes("public"),
publicBypass: routes("public-bypass"),
ticketBypass: routes("ticket-bypass"),
}
export const pending = (method: Method, path: string, name: string, reason: string): TodoScenario => ({
kind: "todo",
method,
path,
name,
reason,
})
export function route(template: string, params: Record<string, string>) {
return Object.entries(params).reduce(
(next, [key, value]) => next.replaceAll(`{${key}}`, value).replaceAll(`:${key}`, value),
template,
)
}
export function controlledPtyInput(title: string | undefined) {
return {
command: "/bin/sh",
args: ["-c", "sleep 30"],
...(title ? { title } : {}),
}
}

View File

@@ -0,0 +1,40 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect } from "effect"
import path from "path"
const preserveExerciseGlobalRoot = !!process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL
export const exerciseGlobalRoot =
process.env.OPENCODE_HTTPAPI_EXERCISE_GLOBAL ??
path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-global-${process.pid}`)
process.env.XDG_DATA_HOME = path.join(exerciseGlobalRoot, "data")
process.env.XDG_CONFIG_HOME = path.join(exerciseGlobalRoot, "config")
process.env.XDG_STATE_HOME = path.join(exerciseGlobalRoot, "state")
process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache")
process.env.OPENCODE_DISABLE_SHARE = "true"
export const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode")
export const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "opencode")
const preserveExerciseDatabase = !!process.env.OPENCODE_HTTPAPI_EXERCISE_DB
export const exerciseDatabasePath =
process.env.OPENCODE_HTTPAPI_EXERCISE_DB ??
path.join(process.env.TMPDIR ?? "/tmp", `opencode-httpapi-exercise-${process.pid}.db`)
process.env.OPENCODE_DB = exerciseDatabasePath
Flag.OPENCODE_DB = exerciseDatabasePath
export const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
export const cleanupExercisePaths = Effect.promise(async () => {
const fs = await import("fs/promises")
if (!preserveExerciseDatabase) {
await Promise.all(
[exerciseDatabasePath, `${exerciseDatabasePath}-wal`, `${exerciseDatabasePath}-shm`].map((file) =>
fs.rm(file, { force: true }).catch(() => undefined),
),
)
}
if (!preserveExerciseGlobalRoot)
await fs.rm(exerciseGlobalRoot, { recursive: true, force: true }).catch(() => undefined)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
import { Duration } from "effect"
import { indent, pad } from "./assertions"
import type { Options, Result, Scenario } from "./types"
export const color = {
dim: "\x1b[2m",
green: "\x1b[32m",
red: "\x1b[31m",
yellow: "\x1b[33m",
cyan: "\x1b[36m",
reset: "\x1b[0m",
}
export function printHeader(
options: Options,
effectRoutes: string[],
selected: Scenario[],
missing: string[],
extra: Scenario[],
paths: { database: string; global: string },
) {
console.log(`${color.cyan}HttpApi exerciser${color.reset}`)
console.log(`${color.dim}db=${paths.database}${color.reset}`)
console.log(`${color.dim}global=${paths.global}${color.reset}`)
console.log(
`${color.dim}mode=${options.mode} selected=${selected.length} scenarioTimeout=${Duration.format(options.scenarioTimeout)} effectRoutes=${effectRoutes.length} missing=${missing.length} extra=${extra.length}${color.reset}`,
)
console.log("")
}
export function printResults(results: Result[], missing: string[], extra: Scenario[]) {
for (const result of results) {
if (result.status === "pass") {
console.log(
`${color.green}PASS${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`,
)
continue
}
if (result.status === "skip") {
console.log(
`${color.yellow}SKIP${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name} ${color.dim}${result.scenario.reason}${color.reset}`,
)
continue
}
console.log(
`${color.red}FAIL${color.reset} ${pad(result.scenario.method, 6)} ${pad(result.scenario.path, 48)} ${result.scenario.name}`,
)
console.log(`${color.red}${indent(result.message)}${color.reset}`)
}
if (missing.length > 0) {
console.log("\nMissing scenarios")
for (const route of missing) console.log(`${color.red}MISS${color.reset} ${route}`)
}
if (extra.length > 0) {
console.log("\nExtra scenarios")
for (const scenario of extra)
console.log(`${color.yellow}EXTRA${color.reset} ${routeKey(scenario)} ${scenario.name}`)
}
console.log(
`\n${color.dim}summary pass=${results.filter((result) => result.status === "pass").length} fail=${results.filter((result) => result.status === "fail").length} skip=${results.filter((result) => result.status === "skip").length} missing=${missing.length} extra=${extra.length}${color.reset}`,
)
}
function routeKey(scenario: Scenario) {
return `${scenario.method} ${scenario.path}`
}

View File

@@ -0,0 +1,96 @@
import { Duration } from "effect"
import { OpenApiMethods, type OpenApiSpec, type Options, type Result, type Scenario } from "./types"
type ScenarioTimeout = `${number} ${Duration.Unit}`
const durationUnits = new Set<string>([
"nano",
"nanos",
"micro",
"micros",
"milli",
"millis",
"second",
"seconds",
"minute",
"minutes",
"hour",
"hours",
"day",
"days",
"week",
"weeks",
])
export function routeKeys(spec: OpenApiSpec) {
return Object.entries(spec.paths ?? {})
.flatMap(([path, item]) =>
OpenApiMethods.filter((method) => item[method]).map((method) => `${method.toUpperCase()} ${path}`),
)
.sort()
}
export function routeKey(scenario: Scenario) {
return `${scenario.method} ${scenario.path}`
}
export function coverageResult(scenario: Scenario): Result {
if (scenario.kind === "todo") return { status: "skip", scenario }
return { status: "pass", scenario }
}
export function parseOptions(args: string[]): Options {
const mode = option(args, "--mode") ?? "effect"
if (mode !== "effect" && mode !== "coverage" && mode !== "auth") throw new Error(`invalid --mode ${mode}`)
return {
mode,
include: option(args, "--include"),
startAt: option(args, "--start-at"),
stopAt: option(args, "--stop-at"),
failOnMissing: args.includes("--fail-on-missing"),
failOnSkip: args.includes("--fail-on-skip"),
scenarioTimeout: parseScenarioTimeout(option(args, "--scenario-timeout") ?? "30 seconds"),
progress: args.includes("--progress"),
trace: args.includes("--trace"),
}
}
export function matches(options: Options, scenario: Scenario) {
if (!options.include) return true
return (
scenario.name.includes(options.include) ||
scenario.path.includes(options.include) ||
scenario.method.includes(options.include.toUpperCase())
)
}
export function selectedScenarios(options: Options, scenarios: Scenario[]) {
const included = scenarios.filter((scenario) => matches(options, scenario))
const start = options.startAt ? included.findIndex((scenario) => matchesName(options.startAt!, scenario)) : 0
const end = options.stopAt
? included.findIndex((scenario) => matchesName(options.stopAt!, scenario))
: included.length - 1
if (start === -1) throw new Error(`--start-at matched no scenario: ${options.startAt}`)
if (end === -1) throw new Error(`--stop-at matched no scenario: ${options.stopAt}`)
return included.slice(start, end + 1)
}
function matchesName(value: string, scenario: Scenario) {
return scenario.name.includes(value) || scenario.path.includes(value) || scenario.method.includes(value.toUpperCase())
}
function option(args: string[], name: string) {
const index = args.indexOf(name)
if (index === -1) return undefined
return args[index + 1]
}
function parseScenarioTimeout(input: string) {
if (!isScenarioTimeout(input)) throw new Error(`invalid --scenario-timeout ${input}`)
return Duration.fromInputUnsafe(input)
}
function isScenarioTimeout(input: string): input is ScenarioTimeout {
const [amount, unit, extra] = input.trim().split(/\s+/)
return extra === undefined && amount !== undefined && Number.isFinite(Number(amount)) && durationUnits.has(unit ?? "")
}

View File

@@ -0,0 +1,267 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Cause, Duration, Effect, Layer, Scope } from "effect"
import { TestLLMServer } from "../../lib/llm-server"
import type { Config } from "../../../src/config/config"
import type { MessageV2 } from "../../../src/session/message-v2"
import { MessageID, PartID } from "../../../src/session/schema"
import { call, callAuthProbe, disposeApps } from "./backend"
import { original } from "./environment"
import { runtime } from "./runtime"
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
export function runScenario(options: Options) {
return (scenario: Scenario) => {
if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result)
return runActive(options, scenario).pipe(
Effect.timeoutOrElse({
duration: options.scenarioTimeout,
orElse: () => Effect.die(new Error(`scenario timed out after ${Duration.format(options.scenarioTimeout)}`)),
}),
Effect.as({ status: "pass", scenario } as Result),
Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })),
Effect.scoped,
)
}
}
function runActive(options: Options, scenario: ActiveScenario) {
if (options.mode === "auth") return runAuth(scenario)
return withContext(options, scenario, "shared", (ctx) =>
Effect.gen(function* () {
yield* trace(options, scenario, "request start")
const result = yield* call(scenario, ctx)
yield* trace(options, scenario, `response ${result.status}`)
yield* trace(options, scenario, "expect start")
yield* scenario.expect(ctx, ctx.state, result)
yield* trace(options, scenario, "expect done")
}),
)
}
function runAuth(scenario: ActiveScenario) {
return Effect.gen(function* () {
const result = yield* callAuthProbe(scenario, "missing")
if (scenario.auth === "protected") {
if (result.status !== 401) throw new Error(`auth expected 401, got ${result.status}`)
const authed = yield* callAuthProbe(scenario, "valid")
if (authed.status === 401) throw new Error("auth rejected valid credentials")
return
}
if (result.status === 401) throw new Error("auth expected public access, got 401")
if (result.timedOut) throw new Error("auth expected public access, probe timed out")
})
}
function withContext<A, E>(
options: Options,
scenario: ActiveScenario,
label: string,
use: (ctx: SeededContext<unknown>) => Effect.Effect<A, E>,
) {
return Effect.acquireRelease(
Effect.gen(function* () {
yield* trace(options, scenario, `${label} context acquire start`)
const llm = scenario.project?.llm ? yield* TestLLMServer : undefined
const project = scenario.project
const dir = project
? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url)))
: undefined
yield* trace(options, scenario, `${label} context acquire done`)
return { dir, llm }
}),
(ctx) =>
Effect.gen(function* () {
yield* trace(options, scenario, `${label} tmpdir cleanup start`)
yield* Effect.promise(async () => {
await ctx.dir?.[Symbol.asyncDispose]()
}).pipe(Effect.ignore)
yield* trace(options, scenario, `${label} tmpdir cleanup done`)
}),
).pipe(
Effect.flatMap((context) =>
Effect.gen(function* () {
yield* trace(options, scenario, `${label} runtime start`)
const modules = yield* Effect.promise(() => runtime())
const scope = yield* Scope.Scope
const app = yield* Layer.buildWithMemoMap(modules.AppLayer, modules.memoMap, scope)
yield* trace(options, scenario, `${label} runtime done`)
const path = context.dir?.path
const instance = path
? yield* trace(options, scenario, `${label} instance load start`).pipe(
Effect.andThen(
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.provide(app),
Effect.catchCause((cause) =>
Effect.sleep("100 millis").pipe(
Effect.andThen(
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
Effect.provide(app),
),
),
Effect.catchCause(() => Effect.failCause(cause)),
),
),
),
),
Effect.tap(() => trace(options, scenario, `${label} instance load done`)),
)
: undefined
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(app))
const directory = () => {
if (!context.dir?.path) throw new Error("scenario needs a project directory")
return context.dir.path
}
const llm = () => {
if (!context.llm) throw new Error("scenario needs fake LLM")
return context.llm
}
const base: ScenarioContext = {
directory: context.dir?.path,
headers: (extra) => ({
...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}),
...extra,
}),
file: (name, content) =>
Effect.promise(() => {
return Bun.write(`${directory()}/${name}`, content)
}).pipe(Effect.asVoid),
session: (input) =>
run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))),
sessionGet: (sessionID) =>
run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe(
Effect.catchCause(() => Effect.succeed(undefined)),
),
project: () =>
Effect.sync(() => {
if (!instance) throw new Error("scenario needs a project directory")
return instance.project
}),
message: (sessionID, input) =>
Effect.gen(function* () {
const info: SessionV1.User = {
id: MessageID.ascending(),
sessionID,
role: "user",
time: { created: Date.now() },
agent: "build",
model: {
providerID: ProviderV2.ID.opencode,
modelID: ModelV2.ID.make("test"),
},
}
const part: SessionV1.TextPart = {
id: PartID.ascending(),
sessionID,
messageID: info.id,
type: "text",
text: input?.text ?? "hello",
}
yield* run(
modules.Session.Service.use((svc) =>
Effect.gen(function* () {
yield* svc.updateMessage(info)
yield* svc.updatePart(part)
}),
),
)
return { info, part }
}),
messages: (sessionID) =>
run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))),
todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))),
worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input).pipe(Effect.orDie))),
worktreeRemove: (directory) =>
run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)),
llmText: (value) => Effect.suspend(() => llm().text(value)),
llmWait: (count) => Effect.suspend(() => llm().wait(count)),
tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)),
}
yield* trace(options, scenario, `${label} seed start`)
const state = yield* scenario.seed(base)
yield* trace(options, scenario, `${label} seed done`)
yield* trace(options, scenario, `${label} use start`)
const result = yield* use({ ...base, state })
yield* trace(options, scenario, `${label} use done`)
return result
}).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)),
),
Effect.ensuring(scenario.reset ? resetState : Effect.void),
)
}
function trace(options: Options, scenario: ActiveScenario, phase: string) {
return Effect.sync(() => {
if (!options.trace) return
console.log(`[trace] ${scenario.name}: ${phase}`)
})
}
function projectOptions(
project: ProjectOptions,
llmUrl: string | undefined,
): { git?: boolean; config?: Partial<ConfigV1.Info> } {
if (!project.llm || !llmUrl) return { git: project.git, config: project.config }
const fake = fakeLlmConfig(llmUrl)
return {
git: project.git,
config: {
...fake,
...project.config,
provider: {
...fake.provider,
...project.config?.provider,
},
},
}
}
function fakeLlmConfig(url: string): Partial<ConfigV1.Info> {
return {
model: "test/test-model",
small_model: "test/test-model",
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,
},
},
},
}
}
const resetState = Effect.promise(async () => {
const modules = await runtime()
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
await disposeApps()
await modules.disposeAllInstances()
await modules.resetDatabase()
await Bun.sleep(25)
})

View File

@@ -0,0 +1,52 @@
export type Runtime = {
PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"]
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
memoMap: import("effect").Layer.MemoMap
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
Session: (typeof import("../../../src/session/session"))["Session"]
Todo: (typeof import("../../../src/session/todo"))["Todo"]
Worktree: (typeof import("../../../src/worktree"))["Worktree"]
Project: (typeof import("../../../src/project/project"))["Project"]
Tui: typeof import("../../../src/server/shared/tui-control")
disposeAllInstances: (typeof import("../../fixture/fixture"))["disposeAllInstances"]
tmpdir: (typeof import("../../fixture/fixture"))["tmpdir"]
resetDatabase: (typeof import("../../fixture/db"))["resetDatabase"]
}
let runtimePromise: Promise<Runtime> | undefined
export function runtime() {
return (runtimePromise ??= (async () => {
const publicApi = await import("../../../src/server/routes/instance/httpapi/public")
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
const appRuntime = await import("../../../src/effect/app-runtime")
const { Layer } = await import("effect")
const instanceRef = await import("../../../src/effect/instance-ref")
const instanceStore = await import("../../../src/project/instance-store")
const session = await import("../../../src/session/session")
const todo = await import("../../../src/session/todo")
const worktree = await import("../../../src/worktree")
const project = await import("../../../src/project/project")
const tui = await import("../../../src/server/shared/tui-control")
const fixture = await import("../../fixture/fixture")
const db = await import("../../fixture/db")
return {
PublicApi: publicApi.PublicApi,
HttpApiApp: httpApiServer.HttpApiApp,
AppLayer: appRuntime.AppLayer,
memoMap: Layer.makeMemoMapUnsafe(),
InstanceRef: instanceRef.InstanceRef,
InstanceStore: instanceStore.InstanceStore,
Session: session.Session,
Todo: todo.Todo,
Worktree: worktree.Worktree,
Project: project.Project,
Tui: tui,
disposeAllInstances: fixture.disposeAllInstances,
tmpdir: fixture.tmpdir,
resetDatabase: db.resetDatabase,
}
})())
}

View File

@@ -0,0 +1,123 @@
import type { Duration, Effect } from "effect"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { Config } from "../../../src/config/config"
import type { Project } from "../../../src/project/project"
import type { Worktree } from "../../../src/worktree"
import type { MessageV2 } from "../../../src/session/message-v2"
import type { SessionID } from "../../../src/session/schema"
export const OpenApiMethods = ["get", "post", "put", "delete", "patch"] as const
export const Methods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const
export type Method = (typeof Methods)[number]
export type OpenApiMethod = (typeof OpenApiMethods)[number]
export type Mode = "effect" | "coverage" | "auth"
export type Comparison = "none" | "status" | "json"
export type CaptureMode = "full" | "stream"
export type AuthPolicy = "protected" | "public" | "public-bypass" | "ticket-bypass"
export type ProjectOptions = { git?: boolean; config?: Partial<ConfigV1.Info>; llm?: boolean }
export type OpenApiSpec = { paths?: Record<string, Partial<Record<OpenApiMethod, unknown>>> }
export type JsonObject = Record<string, unknown>
export type Options = {
mode: Mode
include: string | undefined
startAt: string | undefined
stopAt: string | undefined
failOnMissing: boolean
failOnSkip: boolean
scenarioTimeout: Duration.Duration
progress: boolean
trace: boolean
}
export type RequestSpec = {
path: string
headers?: Record<string, string>
body?: unknown
}
export type CallResult = {
status: number
contentType: string
body: unknown
text: string
timedOut: boolean
}
export type BackendApp = {
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
}
/** Effect-native helpers available while setting up and asserting a scenario. */
export type ScenarioContext = {
directory: string | undefined
headers: (extra?: Record<string, string>) => Record<string, string>
file: (name: string, content: string) => Effect.Effect<void>
session: (input?: { title?: string; parentID?: SessionID }) => Effect.Effect<SessionInfo>
sessionGet: (sessionID: SessionID) => Effect.Effect<SessionInfo | undefined>
project: () => Effect.Effect<Project.Info>
message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect<MessageSeed>
messages: (sessionID: SessionID) => Effect.Effect<SessionV1.WithParts[]>
todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect<void>
worktree: (input?: { name?: string }) => Effect.Effect<Worktree.Info>
worktreeRemove: (directory: string) => Effect.Effect<void>
llmText: (value: string) => Effect.Effect<void>
llmWait: (count: number) => Effect.Effect<void>
tuiRequest: (request: { path: string; body: unknown }) => Effect.Effect<void>
}
/** Scenario context after `.seeded(...)`; `state` preserves the seed return type in the DSL. */
export type SeededContext<S> = ScenarioContext & {
state: S
}
export type Scenario = ActiveScenario | TodoScenario
export type ActiveScenario = {
kind: "active"
method: Method
path: string
name: string
project: ProjectOptions | undefined
seed: (ctx: ScenarioContext) => Effect.Effect<unknown>
request: (ctx: ScenarioContext, state: unknown) => RequestSpec
authProbe: RequestSpec | undefined
expect: (ctx: ScenarioContext, state: unknown, result: CallResult) => Effect.Effect<void>
compare: Comparison
capture: CaptureMode
mutates: boolean
reset: boolean
auth: AuthPolicy
}
export type BuilderState<S> = {
method: Method
path: string
name: string
project: ProjectOptions | undefined
seed: (ctx: ScenarioContext) => Effect.Effect<S>
request: (ctx: SeededContext<S>) => RequestSpec
authProbe: RequestSpec | undefined
capture: CaptureMode
mutates: boolean
reset: boolean
auth: AuthPolicy
}
export type TodoScenario = {
kind: "todo"
method: Method
path: string
name: string
reason: string
}
export type Result =
| { status: "pass"; scenario: ActiveScenario }
| { status: "fail"; scenario: ActiveScenario; message: string }
| { status: "skip"; scenario: TodoScenario }
export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID }
export type TodoInfo = { content: string; status: string; priority: string }
export type MessageSeed = { info: SessionV1.User; part: SessionV1.TextPart }

View File

@@ -0,0 +1,297 @@
import { afterEach, describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { eq } from "drizzle-orm"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { Session } from "@/session/session"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@opencode-ai/core/database/database"
import { AccountV2 } from "@opencode-ai/core/account"
import { AccountTable } from "@opencode-ai/core/account/sql"
import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance
function request(path: string, directory: string, init: RequestInit = {}) {
return requestInDirectory(path, directory, init)
}
function createSession(input?: Session.CreateInput) {
return Session.use.create(input)
}
function json<T>(response: HttpClientResponse.HttpClientResponse) {
return response.json.pipe(Effect.map((value) => value as T))
}
function waitReady(input: { directory?: string; name?: string }) {
return Effect.gen(function* () {
const ready = yield* Deferred.make<void>()
const on = (event: GlobalEvent) => {
if (event.payload.type !== Worktree.Event.Ready.type) return
if (input.directory && event.directory !== input.directory) return
if (input.name && event.payload.properties.name !== input.name) return
Deferred.doneUnsafe(ready, Effect.void)
}
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
return yield* Deferred.await(ready).pipe(
Effect.timeoutOrElse({
duration: "10 seconds",
orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
}),
)
})
}
function insertAccount() {
return Effect.acquireRelease(
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(AccountTable)
.values({
id: AccountV2.ID.make("account-test"),
email: "test@example.com",
url: "https://console.example.com",
access_token: AccountV2.AccessToken.make("access"),
refresh_token: AccountV2.RefreshToken.make("refresh"),
time_created: Date.now(),
time_updated: Date.now(),
})
.run()
.pipe(Effect.orDie)
return "account-test"
}),
(id) =>
Database.Service.use(({ db }) =>
db
.delete(AccountTable)
.where(eq(AccountTable.id, AccountV2.ID.make(id)))
.run()
.pipe(Effect.orDie),
),
)
}
function setSessionUpdated(session: Session.Info, updated: number) {
return Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({ time_updated: updated })
.where(eq(SessionTable.id, session.id))
.run()
.pipe(Effect.orDie)
})
}
function withCreatedWorktree(
directory: string,
use: (info: Worktree.Info) => Effect.Effect<void, unknown, HttpClient.HttpClient>,
) {
const name = "api-test"
const headers = { "content-type": "application/json" }
return Effect.acquireUseRelease(
Effect.gen(function* () {
const ready = yield* waitReady({ name }).pipe(Effect.forkScoped)
const created = yield* request(ExperimentalPaths.worktree, directory, {
method: "POST",
headers,
body: JSON.stringify({ name }),
})
expect(created.status).toBe(200)
const info = yield* json<Worktree.Info>(created)
expect(info).toMatchObject({ name, branch: "opencode/api-test" })
yield* Fiber.join(ready)
return info
}),
use,
(info) =>
Effect.gen(function* () {
const removed = yield* request(ExperimentalPaths.worktree, directory, {
method: "DELETE",
headers,
body: JSON.stringify({ directory: info.directory }),
})
if (removed.status !== 200) return yield* Effect.fail(new Error(`failed to remove worktree: ${removed.status}`))
const ok = yield* json<boolean>(removed)
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${info.directory}`))
}),
)
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("experimental HttpApi", () => {
it.instance(
"serves read-only experimental endpoints through the default server app",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const directory = tmp.directory
const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = yield* Effect.all(
[
request(ExperimentalPaths.console, directory),
request(ExperimentalPaths.consoleOrgs, directory),
request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, directory),
request(ExperimentalPaths.toolIDs, directory),
request(ExperimentalPaths.worktree, directory),
request(ExperimentalPaths.resource, directory),
],
{ concurrency: "unbounded" },
)
expect(consoleState.status).toBe(200)
expect(yield* json(consoleState)).toEqual({
consoleManagedProviders: [],
switchableOrgCount: 0,
})
expect(consoleOrgs.status).toBe(200)
expect(yield* json(consoleOrgs)).toEqual({ orgs: [] })
expect(toolList.status).toBe(200)
expect(yield* json<unknown[]>(toolList)).toContainEqual(
expect.objectContaining({
id: "bash",
description: expect.any(String),
parameters: expect.any(Object),
}),
)
expect(toolIDs.status).toBe(200)
expect(yield* json(toolIDs)).toContain("bash")
expect(worktrees.status).toBe(200)
expect(yield* json(worktrees)).toEqual([])
expect(resources.status).toBe(200)
expect(yield* json(resources)).toEqual({})
}),
{
config: {
formatter: false,
lsp: false,
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
},
)
it.instance("returns declared worktree errors", () =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const response = yield* request(ExperimentalPaths.worktree, tmp.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
})
expect(response.status).toBe(400)
expect(yield* json(response)).toEqual({
name: "WorktreeNotGitError",
data: { message: "Worktrees are only supported for git projects" },
})
}),
)
it.instance(
"serves Console org switch through the default server app",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const accountID = yield* insertAccount()
const switched = yield* request(ExperimentalPaths.consoleSwitch, tmp.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ accountID, orgID: "org-test" }),
})
expect(switched.status).toBe(200)
expect(yield* json(switched)).toBe(true)
}),
{ config: { formatter: false, lsp: false } },
)
it.instance(
"serves global session list through the default server app",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const first = yield* createSession({ title: "page-one" })
const second = yield* createSession({ title: "page-two" })
yield* setSessionUpdated(first, 1)
yield* setSessionUpdated(second, 2)
const page = yield* request(
`${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.directory, limit: "1" })}`,
tmp.directory,
)
expect(page.status).toBe(200)
expect(page.headers["x-next-cursor"]).toBeTruthy()
const body = yield* json<Session.GlobalInfo[]>(page)
expect(body.map((session) => session.id)).toEqual([second.id])
expect(body[0].project?.id).toBe(second.projectID)
const next = yield* request(
`${ExperimentalPaths.session}?${new URLSearchParams({
directory: tmp.directory,
limit: "10",
cursor: body[0].time.updated.toString(),
})}`,
tmp.directory,
)
expect(next.status).toBe(200)
expect((yield* json<Session.GlobalInfo[]>(next)).map((session) => session.id)).toContain(first.id)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
testWorktreeMutations(
"serves worktree mutations through the default server app",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
yield* withCreatedWorktree(tmp.directory, (info) =>
Effect.gen(function* () {
const listed = yield* request(ExperimentalPaths.worktree, tmp.directory)
expect(listed.status).toBe(200)
expect(yield* json(listed)).toContain(info.directory)
const reset = yield* request(ExperimentalPaths.worktreeReset, tmp.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ directory: info.directory }),
})
expect(reset.status).toBe(200)
expect(yield* json(reset)).toBe(true)
}),
)
const afterRemove = yield* request(ExperimentalPaths.worktree, tmp.directory)
expect(afterRemove.status).toBe(200)
expect(yield* json(afterRemove)).toEqual([])
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context } from "effect"
import path from "path"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const context = Context.empty() as Context.Context<unknown>
function request(route: string, directory: string, query?: Record<string, string>) {
const url = new URL(`http://localhost${route}`)
for (const [key, value] of Object.entries(query ?? {})) {
url.searchParams.set(key, value)
}
return HttpApiApp.webHandler().handler(
new Request(url, {
headers: {
"x-opencode-directory": directory,
},
}),
context,
)
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("file HttpApi", () => {
test("serves read endpoints", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "hello.txt"), "hello")
const [list, content, status] = await Promise.all([
request(FilePaths.list, tmp.path, { path: "." }),
request(FilePaths.content, tmp.path, { path: "hello.txt" }),
request(FilePaths.status, tmp.path),
])
expect(list.status).toBe(200)
expect(await list.json()).toContainEqual(
expect.objectContaining({ name: "hello.txt", path: "hello.txt", type: "file" }),
)
expect(content.status).toBe(200)
expect(await content.json()).toMatchObject({ type: "text", content: "hello" })
expect(status.status).toBe(200)
expect(await status.json()).toEqual([])
})
test("serves search endpoints", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "hello.txt"), "needle")
const [text, files, symbols] = await Promise.all([
request(FilePaths.findText, tmp.path, { pattern: "needle" }),
request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }),
request(FilePaths.findSymbol, tmp.path, { query: "hello" }),
])
expect(text.status).toBe(200)
expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 }))
expect(files.status).toBe(200)
expect(await files.json()).toContain("hello.txt")
expect(symbols.status).toBe(200)
expect(await symbols.json()).toEqual([])
})
})

View File

@@ -0,0 +1,66 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Option } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { Installation } from "../../src/installation"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { ServerAuth } from "../../src/server/auth"
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
import { controlPlaneHandlers } from "../../src/server/routes/instance/httpapi/handlers/control-plane"
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
import { testEffect } from "../lib/effect"
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
// Raw HttpApi routes expose an opaque handler context at the request boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
HttpRouter.provideRequest(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(Layer.mock(Auth.Service)({})),
Layer.provide(Layer.mock(Config.Service)({})),
Layer.provide(Layer.mock(MoveSession.Service)({})),
Layer.provide(
Layer.mock(Installation.Service)({
method: () => Effect.succeed("npm"),
latest: () => Effect.succeed("9.9.9"),
upgrade: () => Effect.void,
}),
),
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
)
const it = testEffect(apiLayer)
describe("global HttpApi", () => {
it.live("upgrades to latest when the request body is omitted", () =>
Effect.gen(function* () {
const response = yield* HttpClient.post(GlobalPaths.upgrade)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({ success: true, version: "9.9.9" })
}),
)
it.live("rejects malformed upgrade payloads", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe(
HttpClientRequest.setBody(HttpBody.text("{", "application/json")),
HttpClient.execute,
)
expect(response.status).toBe(400)
expect(yield* response.json).toEqual({ success: false, error: "Invalid request body" })
}),
)
})

View File

@@ -0,0 +1,348 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import {
InstanceContextMiddleware,
instanceContextLayer,
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { withFixedWorkspaceID } from "../fixture/flag"
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
import { waitGlobalBusEvent } from "./global-bus"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await disposeAllInstances()
await resetDatabase()
}),
)
}),
)
const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
InstanceLayer.layer,
Project.defaultLayer,
workspaceLayer,
).pipe(Layer.provide(Ripgrep.defaultLayer)),
)
const instanceContextTestLayer = Layer.mergeAll(
instanceContextLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
)
const localAdapter = (directory: string): WorkspaceAdapter => ({
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.gen(function* () {
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
const workspace = yield* Workspace.Service
return yield* workspace.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
})
}),
(info) => Workspace.use.remove(info.id).pipe(Effect.ignore),
)
const probeInstanceContext = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return {
directory: instance?.directory,
worktree: instance?.worktree,
projectID: instance?.project.id,
workspaceID,
}
})
const ProbeResult = Schema.Struct({
directory: Schema.optional(Schema.String),
worktree: Schema.optional(Schema.String),
projectID: Schema.optional(Schema.String),
workspaceID: Schema.optional(Schema.String),
})
const ProbeApi = HttpApi.make("instance-context-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.post("dispose", "/dispose-probe", {
query: WorkspaceRoutingQuery,
success: Schema.Boolean,
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
)
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("get", () => probeInstanceContext)
.handle("session", () => probeInstanceContext)
.handle(
"dispose",
Effect.fn("InstanceContextProbe.dispose")(function* () {
const instance = yield* InstanceRef
if (!instance) return false
yield* markInstanceForDisposal(instance)
return true
}),
),
)
const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(instanceContextTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
)
const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
const waitDisposedEvent = waitGlobalBusEvent({
message: "timed out waiting for instance disposal",
predicate: (event) => event.payload.type === "server.instance.disposed",
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
const serveDisposeProbe = () =>
HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe(
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,
workspaceID: null,
})
}),
)
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()
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,
})
}),
)
it.live("uses configured workspace id instead of routing to the requested workspace", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
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-fixed-workspace-ref",
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: dir,
workspaceID: fixedWorkspaceID,
})
}),
)
it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
const dir = yield* tmpdirScoped({ git: true })
yield* Project.use.fromDirectory(dir)
yield* serveProbe()
// Reference a workspace id that is not registered locally. Without the
// configured env override, this would short-circuit to a 500
// MissingWorkspace response. With the env set, planRequest must skip the
// MissingWorkspace branch and fall through to Local with the configured
// workspace id.
const unknownWorkspaceID = WorkspaceV2.ID.ascending()
const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
HttpClient.execute,
)
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({
directory: dir,
workspaceID: fixedWorkspaceID,
})
}),
)
it.live("keeps configured workspace id on control-plane routes without remote routing", () =>
Effect.gen(function* () {
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
yield* withFixedWorkspaceID(fixedWorkspaceID)
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-fixed-workspace-control-plane",
directory: workspaceDir,
})
// /session is matched by isLocalWorkspaceRoute, so shouldStayOnControlPlane
// is true. Combined with the env override, the route must stay Local with
// the configured workspace id (not divert to the requested workspace's
// local directory).
yield* serveProbe()
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: fixedWorkspaceID,
})
}),
)
it.live("preserves selected workspace id on instance disposal events", () =>
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-dispose-event",
directory: workspaceDir,
})
yield* serveDisposeProbe()
const disposed = yield* waitDisposedEvent.pipe(Effect.forkScoped({ startImmediately: true }))
const response = yield* HttpClientRequest.post(`/dispose-probe?workspace=${workspace.id}`).pipe(
HttpClient.execute,
)
expect(response.status).toBe(200)
expect(yield* response.json).toBe(true)
expect(yield* Fiber.join(disposed)).toEqual({ directory: workspaceDir, workspace: workspace.id })
}),
)
})

View File

@@ -0,0 +1,81 @@
import { afterEach, describe, expect, test } from "bun:test"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { ServerAuth } from "../../src/server/auth"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
function app(input: { password?: string; username?: string }) {
const handler = HttpRouter.toWebHandler(
HttpApiApp.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, HttpApiApp.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function basic(username: string, password: string) {
return ServerAuth.header({ username, password }) ?? ""
}
async function cancelBody(response: Response) {
await response.body?.cancel().catch(() => {})
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("HttpApi instance route authorization", () => {
test("requires configured auth before opening the instance event stream", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const headers = { "x-opencode-directory": tmp.path }
const missing = await server.request(EventPaths.event, { headers })
await cancelBody(missing)
expect(missing.status).toBe(401)
const authed = await server.request(EventPaths.event, {
headers: { ...headers, authorization: basic("opencode", "secret") },
})
await cancelBody(authed)
expect(authed.status).toBe(200)
})
test("requires configured auth before resolving the PTY websocket route", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
const headers = { "x-opencode-directory": tmp.path }
const missing = await server.request(route, { headers })
await cancelBody(missing)
expect(missing.status).toBe(401)
const authed = await server.request(route, {
headers: { ...headers, authorization: basic("opencode", "secret") },
})
await cancelBody(authed)
expect(authed.status).toBe(404)
})
})

View File

@@ -0,0 +1,265 @@
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Config, Context, Effect, FileSystem, Layer, Path } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { ProjectV2 } from "@opencode-ai/core/project"
import { QuestionID } from "../../src/question/schema"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
import { resetDatabase } from "../fixture/db"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
// Flip the experimental workspaces flag so EventV2.run actually writes to
// EventSequenceTable (the source of truth the fence middleware reads). Reset
// the database around the test so per-instance state does not leak between
// runs. resetDatabase() already calls disposeAllInstances(), so we don't
// repeat it.
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await resetDatabase()
}),
)
}),
)
// Mount the production HttpApi route tree on a real Node HTTP server bound to
// 127.0.0.1:0 and a fetch-based HttpClient that prepends the server URL. This
// keeps the test wired directly through the same route layer production uses.
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{ disableListenLog: true, disableLogger: true },
)
const httpApiServerLayer = servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
)
const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer))
const handlerContext = Context.empty() as Context.Context<unknown>
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
describe("instance HttpApi", () => {
it.live("serves the OpenAPI document", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get("/doc")
expect(response.status).toBe(200)
expect(response.headers["content-type"]).toContain("application/json")
expect(yield* response.json).toMatchObject({
openapi: expect.any(String),
info: expect.any(Object),
paths: expect.objectContaining({
"/global/health": expect.any(Object),
"/session": expect.any(Object),
}),
})
}),
)
it.live("emits a sync fence header for fixed-workspace mutations", () =>
Effect.gen(function* () {
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
}),
)
const dir = yield* tmpdirScoped({ git: true })
const response = yield* HttpClientRequest.post(SessionPaths.create).pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ title: "fenced" }),
Effect.flatMap(HttpClient.execute),
)
expect(response.status).toBe(200)
expect(JSON.parse(response.headers[FenceHeader] ?? "{}")).not.toEqual({})
}),
)
it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () =>
Effect.gen(function* () {
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
}),
)
const dir = yield* tmpdirScoped({ git: true })
const read = yield* HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute)
const log = yield* HttpClientRequest.post(ControlPaths.log).pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ service: "fence-test", level: "info", message: "noop" }),
Effect.flatMap(HttpClient.execute),
)
expect(read.status).toBe(200)
expect(read.headers[FenceHeader]).toBeUndefined()
expect(log.status).toBe(200)
expect(log.headers[FenceHeader]).toBeUndefined()
}),
)
it.live("rejects malformed permission and question request ids", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const request = (path: string, init?: RequestInit) =>
Effect.promise(() =>
HttpApiApp.webHandler().handler(
new Request(`http://localhost${path}`, {
...init,
headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers },
}),
handlerContext,
),
)
const [permission, questionReply, questionReject] = yield* Effect.all(
[
request("/permission/invalid-permission-id/reply", {
method: "POST",
body: JSON.stringify({ reply: "once" }),
}),
request("/question/invalid-question-id/reply", {
method: "POST",
body: JSON.stringify({ answers: [["Yes"]] }),
}),
request("/question/invalid-question-id/reject", { method: "POST" }),
],
{ concurrency: "unbounded" },
)
expect(permission.status).toBe(400)
expect(questionReply.status).toBe(400)
expect(questionReject.status).toBe(400)
}),
)
it.live("returns typed not found bodies for missing permission and question requests", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const request = (path: string, init?: RequestInit) =>
Effect.promise(() =>
HttpApiApp.webHandler().handler(
new Request(`http://localhost${path}`, {
...init,
headers: { "x-opencode-directory": dir, "content-type": "application/json", ...init?.headers },
}),
handlerContext,
),
)
const permissionID = PermissionV1.ID.ascending()
const questionReplyID = QuestionID.ascending()
const questionRejectID = QuestionID.ascending()
const [permission, questionReply, questionReject] = yield* Effect.all(
[
request(`/permission/${permissionID}/reply`, {
method: "POST",
body: JSON.stringify({ reply: "once" }),
}),
request(`/question/${questionReplyID}/reply`, {
method: "POST",
body: JSON.stringify({ answers: [["Yes"]] }),
}),
request(`/question/${questionRejectID}/reject`, { method: "POST" }),
],
{ concurrency: "unbounded" },
)
expect(permission.status).toBe(404)
expect(yield* Effect.promise(() => permission.json())).toEqual({
_tag: "PermissionNotFoundError",
requestID: permissionID,
message: `Permission request not found: ${permissionID}`,
})
expect(questionReply.status).toBe(404)
expect(yield* Effect.promise(() => questionReply.json())).toEqual({
_tag: "QuestionNotFoundError",
requestID: questionReplyID,
message: `Question request not found: ${questionReplyID}`,
})
expect(questionReject.status).toBe(404)
expect(yield* Effect.promise(() => questionReject.json())).toEqual({
_tag: "QuestionNotFoundError",
requestID: questionRejectID,
message: `Question request not found: ${questionRejectID}`,
})
}),
)
it.live("returns typed not found bodies for missing projects", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const projectID = ProjectV2.ID.make("project_missing")
const response = yield* Effect.promise(() =>
HttpApiApp.webHandler().handler(
new Request(`http://localhost/project/${projectID}`, {
method: "PATCH",
headers: { "x-opencode-directory": dir, "content-type": "application/json" },
body: JSON.stringify({ name: "Missing" }),
}),
handlerContext,
),
)
expect(response.status).toBe(404)
expect(yield* Effect.promise(() => response.json())).toEqual({
_tag: "ProjectNotFoundError",
projectID,
message: `Project not found: ${projectID}`,
})
}),
)
it.live("serves path and VCS read endpoints", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(dir, "changed.txt"), "hello")
const [paths, vcs, diff] = yield* Effect.all(
[
HttpClientRequest.get(InstancePaths.path).pipe(directoryHeader(dir), HttpClient.execute),
HttpClientRequest.get(InstancePaths.vcs).pipe(directoryHeader(dir), HttpClient.execute),
HttpClientRequest.get(InstancePaths.vcsDiff).pipe(
HttpClientRequest.setUrlParam("mode", "git"),
directoryHeader(dir),
HttpClient.execute,
),
],
{ concurrency: "unbounded" },
)
expect(paths.status).toBe(200)
expect(yield* paths.json).toMatchObject({ directory: dir, worktree: dir })
expect(vcs.status).toBe(200)
expect(yield* vcs.json).toMatchObject({ branch: expect.any(String) })
expect(diff.status).toBe(200)
expect(yield* diff.json).toContainEqual(
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
)
}),
)
})

View File

@@ -0,0 +1,33 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Config, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{
disableListenLog: true,
disableLogger: true,
},
)
export const httpApiLayer = servedRoutes.pipe(
Layer.provide(layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
)
export function request(path: string, init?: RequestInit) {
const url = new URL(path, "http://localhost")
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
HttpClientRequest.setUrl(url.pathname),
HttpClient.execute,
)
}
export function requestInDirectory(path: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return request(path, { ...init, headers })
}

View File

@@ -0,0 +1,412 @@
import { afterEach, describe, expect, test } from "bun:test"
import net from "node:net"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
envUsername: process.env.OPENCODE_SERVER_USERNAME,
}
const auth = { username: "opencode", password: "listen-secret" }
const testPty = process.platform === "win32" ? test.skip : test
afterEach(async () => {
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword
if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME
else process.env.OPENCODE_SERVER_USERNAME = original.envUsername
await disposeAllInstances()
await resetDatabase()
})
async function startListener() {
Flag.OPENCODE_SERVER_PASSWORD = auth.password
Flag.OPENCODE_SERVER_USERNAME = auth.username
process.env.OPENCODE_SERVER_PASSWORD = auth.password
process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
async function startNoAuthListener() {
Flag.OPENCODE_SERVER_PASSWORD = undefined
Flag.OPENCODE_SERVER_USERNAME = auth.username
delete process.env.OPENCODE_SERVER_PASSWORD
process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
function authorization() {
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
}
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string, ticket?: string) {
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
url.protocol = "ws:"
url.searchParams.set("directory", dir)
url.searchParams.set("cursor", "-1")
if (ticket) url.searchParams.set("ticket", ticket)
return url
}
async function requestTicket(
listener: Awaited<ReturnType<typeof startListener>>,
id: string,
dir: string,
options?: { ticketHeader?: boolean; origin?: string },
) {
const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), {
method: "POST",
headers: {
authorization: authorization(),
"x-opencode-directory": dir,
...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }),
...(options?.origin ? { origin: options.origin } : {}),
},
})
return response
}
async function connectTicket(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
const response = await requestTicket(listener, id, dir)
expect(response.status).toBe(200)
return (await response.json()) as { ticket: string; expires_in: number }
}
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
const response = await fetch(new URL(PtyPaths.create, listener.url), {
method: "POST",
headers: {
authorization: authorization(),
"x-opencode-directory": dir,
"content-type": "application/json",
},
body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }),
})
expect(response.status).toBe(200)
return (await response.json()) as { id: string }
}
async function openSocket(url: URL) {
const ws = new WebSocket(url)
ws.binaryType = "arraybuffer"
await withTimeout(
new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true })
ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true })
}),
5_000,
"timed out waiting for websocket open",
)
return ws
}
async function expectSocketRejected(url: URL, init?: { headers?: Record<string, string> }) {
// Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that.
const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record<string, string> }) => WebSocket
const ws = new Ctor(url, init)
await withTimeout(
new Promise<void>((resolve, reject) => {
ws.addEventListener(
"open",
() => {
ws.close(1000)
reject(new Error("websocket opened"))
},
{ once: true },
)
ws.addEventListener("error", () => resolve(), { once: true })
ws.addEventListener("close", () => resolve(), { once: true })
}),
5_000,
"timed out waiting for websocket rejection",
)
}
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
return withTimeout(listener.stop(true), 10_000, label)
}
function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) {
const decoder = new TextDecoder()
let onMessage: ((event: MessageEvent) => void) | undefined
return withTimeout(
new Promise<string>((resolve) => {
onMessage = (event: MessageEvent) => {
const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer)
if (!predicate(message)) return
resolve(message)
}
ws.addEventListener("message", onMessage)
}),
5_000,
"timed out waiting for websocket message",
).finally(() => {
if (onMessage) ws.removeEventListener("message", onMessage)
})
}
async function openPtySocket(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
const info = await createCat(listener, dir)
const ticket = await connectTicket(listener, info.id, dir)
const ws = await openSocket(socketURL(listener, info.id, dir, ticket.ticket))
return {
ws,
closed: new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true })),
}
}
describe("HttpApi Server.listen", () => {
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const listener = await startListener()
let stopped = false
try {
const response = await fetch(new URL(PtyPaths.shells, listener.url), {
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: expect.any(String),
name: expect.any(String),
acceptable: expect.any(Boolean),
}),
]),
)
const info = await createCat(listener, tmp.path)
const ticket = await connectTicket(listener, info.id, tmp.path)
expect(ticket.expires_in).toBeGreaterThan(0)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
ws.send("ping-listen\n")
expect(await message).toContain("ping-listen")
await stop(listener, "timed out waiting for listener.stop(true)")
stopped = true
await withTimeout(closed, 5_000, "timed out waiting for websocket close")
expect(ws.readyState).toBe(WebSocket.CLOSED)
const restarted = await startListener()
try {
const nextInfo = await createCat(restarted, tmp.path)
const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path)
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket))
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
nextWs.send("ping-restarted\n")
expect(await nextMessage).toContain("ping-restarted")
nextWs.close(1000)
} finally {
await stop(restarted, "timed out waiting for restarted listener.stop(true)")
}
} finally {
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
}
})
testPty("stop(true) is safe when called concurrently and repeatedly", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
let stopped = false
try {
const socket = await openPtySocket(listener, tmp.path)
await withTimeout(
Promise.all([listener.stop(true), listener.stop(true)]).then(() => undefined),
10_000,
"timed out waiting for concurrent listener.stop(true)",
)
await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close after concurrent stop")
await withTimeout(listener.stop(true), 5_000, "timed out waiting for repeated listener.stop(true)")
stopped = true
} finally {
if (!stopped) await stop(listener, "timed out cleaning up concurrent stop listener").catch(() => undefined)
}
})
testPty("stop(true) can force a graceful stop already in progress", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
let stopped = false
try {
const socket = await openPtySocket(listener, tmp.path)
const graceful = listener.stop()
const forced = listener.stop(true)
await withTimeout(
Promise.all([graceful, forced]).then(() => undefined),
10_000,
"timed out waiting for forced listener stop",
)
await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close after forced stop")
stopped = true
} finally {
if (!stopped) await stop(listener, "timed out cleaning up forced stop listener").catch(() => undefined)
}
})
testPty("graceful stop waits for an overlapping forced stop", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
let stopped = false
try {
const socket = await openPtySocket(listener, tmp.path)
const forced = listener.stop(true)
await withTimeout(listener.stop(), 10_000, "timed out waiting for graceful stop after forced stop")
stopped = true
await withTimeout(forced, 5_000, "timed out waiting for overlapping forced stop")
await withTimeout(socket.closed, 5_000, "timed out waiting for websocket close before graceful stop resolved")
} finally {
if (!stopped) await stop(listener, "timed out cleaning up overlapping stop listener").catch(() => undefined)
}
})
test("stop() gracefully closes an idle listener and is repeat-safe", async () => {
const listener = await startListener()
await withTimeout(listener.stop(), 10_000, "timed out waiting for graceful listener.stop()")
await withTimeout(listener.stop(), 5_000, "timed out waiting for repeated graceful listener.stop()")
await expect(
fetch(new URL(PtyPaths.shells, listener.url), { headers: { authorization: authorization() } }),
).rejects.toThrow()
})
test("default in-process handler does not emit Effect HTTP response logs", async () => {
let output = ""
// oxlint-disable-next-line typescript-eslint/unbound-method -- restored in finally after temporarily capturing stderr.
const original = process.stderr.write
process.stderr.write = ((chunk) => {
output += String(chunk)
return true
}) as typeof process.stderr.write
try {
const response = await Server.Default().app.request("/status")
expect(response.status).toBe(200)
} finally {
process.stderr.write = original
}
expect(output).not.toContain("Sent HTTP response")
})
test("port 0 prefers 4096 when free", async () => {
if (!(await isPortFree(4096))) return
const listener = await startListener()
try {
expect(listener.port).toBe(4096)
} finally {
await stop(listener, "timed out cleaning up port-0 prefers-4096 listener")
}
})
test("port 0 falls back when 4096 is taken", async () => {
const blocker = await occupyPort(4096)
if (!blocker) return
try {
const listener = await startListener()
try {
expect(listener.port).not.toBe(4096)
expect(listener.port).toBeGreaterThan(0)
} finally {
await stop(listener, "timed out cleaning up port-0 fallback listener")
}
} finally {
await new Promise<void>((resolve) => blocker.close(() => resolve()))
}
})
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
// Regression for #25698: minting without a directory uses the server cwd
// and cannot find a PTY registered in a project directory.
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
})
expect(ambiguous.status).toBe(404)
const directoryScoped = await fetch(
new URL(
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
listener.url,
),
{
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
},
)
expect(directoryScoped.status).toBe(200)
const mint = (await directoryScoped.json()) as { ticket: string }
const scopedWs = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
scopedWs.close(1000)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
const reusable = await connectTicket(listener, info.id, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket))
await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket))
ws.close(1000)
const other = await createCat(listener, tmp.path)
const scoped = await connectTicket(listener, info.id, tmp.path)
await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket))
const crossOrigin = await connectTicket(listener, info.id, tmp.path)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), {
headers: { origin: "https://evil.example" },
})
} finally {
await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined)
}
})
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener()
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes("ping-no-auth"))
ws.send("ping-no-auth\n")
expect(await message).toContain("ping-no-auth")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
})
function isPortFree(port: number) {
return new Promise<boolean>((resolve) => {
const probe = net.createServer()
probe.once("error", () => resolve(false))
probe.once("listening", () => probe.close(() => resolve(true)))
probe.listen(port, "127.0.0.1")
})
}
function occupyPort(port: number) {
return new Promise<net.Server | undefined>((resolve) => {
const server = net.createServer()
server.once("error", () => resolve(undefined))
server.listen(port, "127.0.0.1", () => resolve(server))
})
}

View File

@@ -0,0 +1,73 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Session } from "@/session/session"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"
import { McpApi, McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Authorization } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRouteContext,
WorkspaceRoutingMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { testEffect } from "../lib/effect"
const TestHttpApi = HttpApi.make("opencode-instance").addHttpApi(McpApi)
const fakeSession = Layer.mock(Session.Service)({})
const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
Effect.succeed(
handlers
.handle("status", () => Effect.die("unexpected MCP status"))
.handle("add", () => Effect.die("unexpected MCP add"))
.handle("authStart", () =>
Effect.succeed({ authorizationUrl: "https://auth.example/start", oauthState: "state-123" }),
)
.handle("authCallback", () => Effect.die("unexpected MCP authCallback"))
.handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate"))
.handle("authRemove", () => Effect.die("unexpected MCP authRemove"))
.handle("connect", () => Effect.die("unexpected MCP connect"))
.handle("disconnect", () => Effect.die("unexpected MCP disconnect")),
),
)
const passthroughAuthorization = Layer.succeed(
Authorization,
Authorization.of((effect) => effect),
)
const passthroughInstanceContext = Layer.succeed(
InstanceContextMiddleware,
InstanceContextMiddleware.of((effect) => effect),
)
const testWorkspaceRouting = Layer.succeed(
WorkspaceRoutingMiddleware,
WorkspaceRoutingMiddleware.of((effect) =>
effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory: process.cwd() }))),
),
)
const it = testEffect(
HttpRouter.serve(
HttpApiBuilder.layer(TestHttpApi).pipe(
Layer.provide(testMcpHandlers),
Layer.provide([passthroughAuthorization, passthroughInstanceContext, testWorkspaceRouting, fakeSession]),
),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest)),
)
describe("mcp HttpApi OAuth", () => {
it.live("preserves oauth state when starting OAuth", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.post(McpPaths.auth.replace(":name", "demo")).pipe(HttpClient.execute)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
authorizationUrl: "https://auth.example/start",
oauthState: "state-123",
})
}),
)
})

View File

@@ -0,0 +1,223 @@
import { describe, expect } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const context = Context.empty() as Context.Context<unknown>
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()).pipe(Effect.ignore))
}),
)
const it = testEffect(testStateLayer)
function app() {
return Server.Default().app
}
type TestApp = ReturnType<typeof app>
type TestHandler = ReturnType<typeof HttpApiApp.webHandler>
const request = Effect.fnUntraced(function* (
handler: TestHandler,
route: string,
directory: string,
init?: RequestInit,
) {
const headers = new Headers(init?.headers)
headers.set("x-opencode-directory", directory)
return yield* Effect.promise(() =>
Promise.resolve(
handler.handler(
new Request(`http://localhost${route}`, {
...init,
headers,
}),
context,
),
),
)
})
const json = <A>(response: Response) => Effect.promise(() => response.json() as Promise<A>)
const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: string; headers: HeadersInit }) {
const response = yield* Effect.promise(() =>
Promise.resolve(input.app.request(input.path, { method: "POST", headers: input.headers })),
)
return {
status: response.status,
body: yield* Effect.promise(() => response.text()),
}
})
describe("mcp HttpApi", () => {
it.instance(
"serves status endpoint",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const handler = HttpApiApp.webHandler()
const response = yield* request(handler, McpPaths.status, tmp.directory)
expect(response.status).toBe(200)
expect(yield* json(response)).toEqual({ demo: { status: "disabled" } })
}),
{
config: {
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
},
)
it.instance(
"serves add, connect, and disconnect endpoints",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const handler = HttpApiApp.webHandler()
const added = yield* request(handler, McpPaths.status, tmp.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "added",
config: {
type: "local",
command: ["echo", "added"],
enabled: false,
},
}),
})
expect(added.status).toBe(200)
expect(yield* json(added)).toMatchObject({ added: { status: "disabled" } })
const addedDisconnected = yield* request(handler, "/mcp/added/disconnect", tmp.directory, { method: "POST" })
expect(addedDisconnected.status).toBe(200)
expect(yield* json(addedDisconnected)).toBe(true)
const connected = yield* request(handler, "/mcp/demo/connect", tmp.directory, { method: "POST" })
expect(connected.status).toBe(200)
expect(yield* json(connected)).toBe(true)
const disconnected = yield* request(handler, "/mcp/demo/disconnect", tmp.directory, { method: "POST" })
expect(disconnected.status).toBe(200)
expect(yield* json(disconnected)).toBe(true)
}),
{
config: {
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
},
)
it.instance(
"serves deterministic OAuth endpoints",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const handler = HttpApiApp.webHandler()
const start = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "POST" })
expect(start.status).toBe(400)
const authenticate = yield* request(handler, "/mcp/demo/auth/authenticate", tmp.directory, { method: "POST" })
expect(authenticate.status).toBe(400)
const removed = yield* request(handler, "/mcp/demo/auth", tmp.directory, { method: "DELETE" })
expect(removed.status).toBe(200)
expect(yield* json(removed)).toEqual({ success: true })
}),
{
config: {
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
},
)
it.instance(
"returns unsupported OAuth error responses",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const dir = tmp.directory
const headers = { "x-opencode-directory": dir }
yield* Effect.forEach(["/mcp/demo/auth", "/mcp/demo/auth/authenticate"], (path) =>
Effect.gen(function* () {
const response = yield* readResponse({ app: app(), path, headers })
expect(response).toEqual({
status: 400,
body: JSON.stringify({ error: "MCP server demo does not support OAuth" }),
})
}),
)
}),
{
config: {
formatter: false,
lsp: false,
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
},
)
it.instance(
"returns typed not found errors for missing MCP servers",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const handler = HttpApiApp.webHandler()
for (const input of [
{ method: "POST", route: "/mcp/missing/auth" },
{ method: "POST", route: "/mcp/missing/auth/authenticate" },
{ method: "POST", route: "/mcp/missing/auth/callback", body: JSON.stringify({ code: "code" }) },
{ method: "DELETE", route: "/mcp/missing/auth" },
{ method: "POST", route: "/mcp/missing/connect" },
{ method: "POST", route: "/mcp/missing/disconnect" },
]) {
const response = yield* request(handler, input.route, tmp.directory, {
method: input.method,
headers: input.body ? { "content-type": "application/json" } : undefined,
body: input.body,
})
expect(response.status).toBe(404)
expect(yield* json(response)).toEqual({
_tag: "McpServerNotFoundError",
name: "missing",
message: "MCP server not found: missing",
})
}
}),
{ config: { mcp: {} } },
)
})

View File

@@ -0,0 +1,79 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances } from "../fixture/fixture"
type Event = { kind: "publish"; port: number; name: string } | { kind: "unpublishAll" } | { kind: "destroy" }
const events: Event[] = []
void mock.module("bonjour-service", () => ({
Bonjour: class {
publish(opts: { port: number; name: string }) {
events.push({ kind: "publish", port: opts.port, name: opts.name })
return { on: () => {} }
}
unpublishAll() {
events.push({ kind: "unpublishAll" })
}
destroy() {
events.push({ kind: "destroy" })
}
},
}))
// Import Server AFTER the mock so the MDNS module picks up the stub.
const { Server } = await import("../../src/server/server")
const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
afterEach(async () => {
events.length = 0
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
await disposeAllInstances()
await resetDatabase()
})
describe("HttpApi Server.listen mDNS", () => {
test("skips publish for loopback hostnames", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode"
const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, mdns: true })
try {
expect(events.filter((e) => e.kind === "publish")).toEqual([])
} finally {
await withTimeout(listener.stop(true), 10_000, "timed out stopping loopback mdns listener")
}
expect(events.filter((e) => e.kind === "publish")).toEqual([])
})
test("publishes for non-loopback hostnames and unpublishes on stop", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode"
const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true })
try {
const published = events.filter((e) => e.kind === "publish")
expect(published.length).toBe(1)
expect(published[0]!.port).toBe(listener.port)
expect(published[0]!.name).toBe(`opencode-${listener.port}`)
} finally {
await withTimeout(listener.stop(true), 10_000, "timed out stopping mdns listener")
}
expect(events.some((e) => e.kind === "unpublishAll")).toBe(true)
expect(events.some((e) => e.kind === "destroy")).toBe(true)
})
test("scope finalizer unpublishes even if stop() is not called for force-close", async () => {
Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret"
Flag.OPENCODE_SERVER_USERNAME = "opencode"
const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true })
expect(events.filter((e) => e.kind === "publish").length).toBe(1)
// Plain (graceful) stop without close=true should still unpublish.
await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener")
expect(events.some((e) => e.kind === "unpublishAll")).toBe(true)
})
})

View File

@@ -0,0 +1,223 @@
// Regression coverage for issue #26526's claim that promptAsync's
// Effect.forkIn loses the request's InstanceRef/WorkspaceRef. It does not —
// forkIn preserves Context.Reference values via standard fiber inheritance.
//
// The companion claim that the streaming prompt handler "captures and
// provides" those services is true and load-bearing: Stream.fromEffect's
// body runs detached from the request fiber's context, so the explicit
// Effect.provideService calls there are required, not defensive duplication.
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { mkdir } from "node:fs/promises"
import { registerAdapter } from "../../src/control-plane/adapters"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import {
InstanceContextMiddleware,
instanceContextLayer,
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await disposeAllInstances()
await resetDatabase()
}),
)
}),
)
const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
InstanceLayer.layer,
Project.defaultLayer,
workspaceLayer,
).pipe(Layer.provide(Ripgrep.defaultLayer)),
)
const instanceContextTestLayer = Layer.mergeAll(
instanceContextLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
)
const localAdapter = (directory: string): WorkspaceAdapter => ({
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 setupWorkspace = (kind: string) =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
yield* Project.use.fromDirectory(dir)
const projectID = yield* Project.Service.use((svc) => svc.fromDirectory(dir).pipe(Effect.map((p) => p.project.id)))
registerAdapter(projectID, kind, localAdapter(dir))
const workspace = yield* Workspace.Service.use((svc) =>
svc.create({ type: kind, branch: null, extra: null, projectID }),
)
return { dir, workspace }
})
type Capture = { directory?: string; workspaceID?: string }
const captureInstance = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return { directory: instance?.directory, workspaceID } satisfies Capture
})
const ProbeApi = HttpApi.make("handler-context-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
}),
HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
)
const serveProbes = (input: {
fork?: Effect.Effect<boolean, never, Scope.Scope>
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
}) =>
HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(
HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("fork", () => input.fork ?? Effect.succeed(false))
.handleRaw(
"streamWithout",
() => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
)
.handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
),
),
Layer.provide(instanceContextTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
describe("HttpApi handler context inheritance", () => {
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
// the request's Context — including InstanceRef and WorkspaceRef provided
// by InstanceContextMiddleware — without any explicit re-provide.
it.live("Effect.forkIn preserves InstanceRef/WorkspaceRef across the fork", () =>
Effect.gen(function* () {
const { dir, workspace } = yield* setupWorkspace("local-fork")
const capture = yield* Deferred.make<Capture>()
yield* serveProbes({
fork: Effect.gen(function* () {
const scope = yield* Scope.Scope
yield* Effect.gen(function* () {
yield* Deferred.succeed(capture, yield* captureInstance)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return true
}),
})
const response = yield* HttpClient.post(
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
)
expect(response.status).toBe(200)
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
expect(observed.directory).toBe(dir)
expect(observed.workspaceID).toBe(workspace.id)
}),
)
// Mirrors handlers/session.ts:255 prompt — the streaming handler reads
// InstanceRef/WorkspaceRef in the request fiber and re-provides them to
// the Stream.fromEffect body. This test locks in why the explicit
// provides are required: without them the stream body sees undefined.
it.live("Stream.fromEffect body needs explicit provides — inheritance does not carry through", () =>
Effect.gen(function* () {
const { dir, workspace } = yield* setupWorkspace("local-stream")
const withoutCapture = yield* Deferred.make<Capture>()
const withCapture = yield* Deferred.make<Capture>()
yield* serveProbes({
streamWithout: Effect.gen(function* () {
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
yield* Deferred.succeed(withoutCapture, yield* captureInstance)
return ""
}),
).pipe(Stream.encodeText),
{ contentType: "application/json" },
)
}),
streamWith: Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
yield* Deferred.succeed(withCapture, yield* captureInstance)
return ""
}).pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspaceID)),
).pipe(Stream.encodeText),
{ contentType: "application/json" },
)
}),
})
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
yield* responseWithout.text
const responseWith = yield* HttpClient.post(`/stream-probe-with?${queryString}`)
yield* responseWith.text
const without = yield* Deferred.await(withoutCapture).pipe(Effect.timeout("2 seconds"))
expect(without.directory).toBeUndefined()
expect(without.workspaceID).toBeUndefined()
const withProvide = yield* Deferred.await(withCapture).pipe(Effect.timeout("2 seconds"))
expect(withProvide.directory).toBe(dir)
expect(withProvide.workspaceID).toBe(workspace.id)
}),
)
})

View File

@@ -0,0 +1,400 @@
import { describe, expect } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Layer } from "effect"
import path from "path"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { markPluginDependenciesReady } from "../fixture/plugin"
import { testEffect } from "../lib/effect"
import { httpApiLayer, request } from "./httpapi-layer"
const testStateLayer = Layer.effectDiscard(
Effect.acquireRelease(
Effect.promise(() => resetDatabase()),
() => Effect.promise(() => resetDatabase()),
),
)
const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, httpApiLayer))
const projectOptions = { config: { formatter: false, lsp: false } }
const providerID = "test-oauth-parity"
const oauthURL = "https://example.com/oauth"
const oauthInstructions = "Finish OAuth"
function providerListHasFetch(list: unknown) {
if (!Array.isArray(list)) return false
return list.some((item: unknown) => {
if (typeof item !== "object" || item === null || !("id" in item) || !("options" in item)) return false
if (item.id !== "google") return false
if (typeof item.options !== "object" || item.options === null) return false
return "fetch" in item.options
})
}
function hasProviderWithFetch(input: unknown, key: "all" | "providers") {
if (typeof input !== "object" || input === null) return false
if (key === "all") return "all" in input && providerListHasFetch(input.all)
return "providers" in input && providerListHasFetch(input.providers)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function providerList(input: unknown, key: "all" | "providers") {
if (!isRecord(input)) return []
if (!Array.isArray(input[key])) return []
return input[key]
}
function providerByID(input: unknown, key: "all" | "providers", id: string) {
return providerList(input, key).find((provider) => isRecord(provider) && provider.id === id)
}
function hasNonZeroModelCost(input: unknown, key: "all" | "providers", id: string) {
const provider = providerByID(input, key, id)
if (!isRecord(provider) || !isRecord(provider.models)) return false
return Object.values(provider.models).some((model) => {
if (!isRecord(model) || !isRecord(model.cost) || !isRecord(model.cost.cache)) return false
return [model.cost.input, model.cost.output, model.cost.cache.read, model.cost.cache.write].some(
(cost) => typeof cost === "number" && cost > 0,
)
})
}
function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id: string) {
const provider = providerByID(input, key, id)
if (!isRecord(provider)) return false
if (provider.name === "mutated-provider") return true
return isRecord(provider.options) && provider.options.mutatedByPlugin === true
}
function requestAuthorize(input: {
providerID: string
method: number
headers: HeadersInit
inputs?: Record<string, string>
}) {
return Effect.gen(function* () {
const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, {
method: "POST",
headers: input.headers,
body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }),
})
return {
status: response.status,
body: yield* response.text,
}
})
}
function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) {
return Effect.gen(function* () {
const response = yield* request(`/provider/${input.providerID}/oauth/callback`, {
method: "POST",
headers: input.headers,
body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }),
})
return {
status: response.status,
body: yield* response.text,
}
})
}
function writeProviderAuthPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
[
"export default {",
' id: "test.provider-oauth-parity",',
" server: async () => ({",
" auth: {",
` provider: "${providerID}",`,
" methods: [",
' { type: "api", label: "API key" },',
" {",
' type: "oauth",',
' label: "OAuth",',
" authorize: async () => ({",
` url: "${oauthURL}",`,
' method: "code",',
` instructions: "${oauthInstructions}",`,
" callback: async () => ({ type: 'success', key: 'token' }),",
" }),",
" },",
" ],",
" },",
" }),",
"}",
"",
].join("\n"),
)
})
}
function writeProviderAuthValidationPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-oauth-validation.ts"),
[
"export default {",
' id: "test.provider-oauth-validation",',
" server: async () => ({",
" auth: {",
' provider: "test-oauth-validation",',
" methods: [",
" {",
' type: "oauth",',
' label: "OAuth",',
" prompts: [",
" {",
' type: "text",',
' key: "token",',
' message: "Token",',
" validate: (value) => value === 'ok' ? undefined : 'Token must be ok',",
" },",
" ],",
" authorize: async () => ({",
` url: "${oauthURL}",`,
' method: "code",',
` instructions: "${oauthInstructions}",`,
" callback: async () => ({ type: 'success', key: 'token' }),",
" }),",
" },",
" ],",
" },",
" }),",
"}",
"",
].join("\n"),
)
})
}
function writeFunctionOptionsPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-function-options.ts"),
[
"export default {",
' id: "test.provider-function-options",',
" server: async () => ({",
" auth: {",
' provider: "google",',
" loader: async (_getAuth, provider) => {",
" for (const model of Object.values(provider.models ?? {})) {",
" model.cost = { input: 0, output: 0 }",
" }",
" return {",
' apiKey: "",',
" fetch: async (input, init) => fetch(input, init),",
" }",
" },",
" methods: [{ type: 'api', label: 'API key' }],",
" },",
" }),",
"}",
"",
].join("\n"),
)
})
}
function writeProviderModelsMutationPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* FSUtil.Service
yield* Effect.promise(() => markPluginDependenciesReady(path.join(dir, ".opencode")))
yield* fs.writeWithDirs(
path.join(dir, ".opencode", "plugin", "provider-models-mutation.ts"),
[
"export default {",
' id: "test.provider-models-mutation",',
" server: async () => ({",
" provider: {",
' id: "google",',
" models: async (provider) => {",
" const models = Object.fromEntries(",
" Object.entries(provider.models ?? {}).map(([id, model]) => [id, { ...model }]),",
" )",
' provider.name = "mutated-provider"',
" provider.options = { ...provider.options, mutatedByPlugin: true }",
" for (const model of Object.values(provider.models ?? {})) {",
" model.cost = { input: 0, output: 0 }",
" }",
" return models",
" },",
" },",
" }),",
"}",
"",
].join("\n"),
)
})
}
function setEnvScoped(key: string, value: string) {
return Effect.acquireRelease(
Effect.sync(() => {
const previous = process.env[key]
process.env[key] = value
return previous
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env[key]
else process.env[key] = previous
}),
)
}
describe("provider HttpApi", () => {
it.instance.skip(
"returns public v2 provider not found errors",
Effect.gen(function* () {
const directory = (yield* TestInstance).directory
const response = yield* request("/api/provider/missing", {
headers: { "x-opencode-directory": directory },
})
expect(response.status).toBe(404)
expect(yield* response.json).toEqual({
_tag: "ProviderNotFoundError",
providerID: "missing",
message: "Provider not found: missing",
})
}),
projectOptions,
)
it.instance(
"serves OAuth authorize response shapes",
Effect.gen(function* () {
const directory = (yield* TestInstance).directory
const headers = { "x-opencode-directory": directory, "content-type": "application/json" }
const api = yield* requestAuthorize({
providerID,
method: 0,
headers,
})
// method 0 (api-key style) — authorize() resolves with no further
// redirect; #26474 changed the wire format to JSON `null` so clients
// can `.json()` parse uniformly instead of getting an empty body
// that throws.
expect(api).toEqual({ status: 200, body: "null" })
const oauth = yield* requestAuthorize({
providerID,
method: 1,
headers,
})
expect(JSON.parse(oauth.body)).toEqual({
url: oauthURL,
method: "code",
instructions: oauthInstructions,
})
}),
{ ...projectOptions, init: writeProviderAuthPlugin },
30000,
)
it.instance(
"returns declared provider auth validation errors",
Effect.gen(function* () {
const directory = (yield* TestInstance).directory
const response = yield* requestAuthorize({
providerID: "test-oauth-validation",
method: 0,
inputs: { token: "nope" },
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
})
expect(response.status).toBe(400)
expect(JSON.parse(response.body)).toEqual({
name: "ProviderAuthValidationFailed",
data: { field: "token", message: "Token must be ok" },
})
}),
{ ...projectOptions, init: writeProviderAuthValidationPlugin },
30000,
)
it.instance(
"returns declared provider auth callback errors",
Effect.gen(function* () {
const directory = (yield* TestInstance).directory
const response = yield* requestCallback({
providerID,
method: 0,
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
})
expect(response.status).toBe(400)
expect(JSON.parse(response.body)).toEqual({
name: "ProviderAuthOauthMissing",
data: { providerID },
})
}),
projectOptions,
30000,
)
it.instance(
"serves provider lists when auth loaders add runtime fetch options",
Effect.gen(function* () {
const directory = (yield* TestInstance).directory
yield* setEnvScoped(
"OPENCODE_AUTH_CONTENT",
JSON.stringify({
google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 },
}),
)
const headers = { "x-opencode-directory": directory }
const providerResponse = yield* request("/provider", { headers })
const configResponse = yield* request("/config/providers", { headers })
expect(providerResponse.status).toBe(200)
expect(configResponse.status).toBe(200)
const providerBody = yield* providerResponse.json
const configBody = yield* configResponse.json
expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true)
}),
{ ...projectOptions, init: writeFunctionOptionsPlugin },
)
it.instance(
"keeps provider.models hook input mutations out of provider state",
Effect.gen(function* () {
const directory = (yield* TestInstance).directory
const headers = { "x-opencode-directory": directory }
const providerResponse = yield* request("/provider", { headers })
const configResponse = yield* request("/config/providers", { headers })
expect(providerResponse.status).toBe(200)
expect(configResponse.status).toBe(200)
const providerBody = yield* providerResponse.json
const configBody = yield* configResponse.json
expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false)
expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false)
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
}),
{ ...projectOptions, init: writeProviderModelsMutationPlugin },
)
})

View File

@@ -0,0 +1,272 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { PtyID } from "@opencode-ai/core/pty/schema"
import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture"
import { Config, Effect, Layer, Queue, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { Pty } from "@opencode-ai/core/pty"
import { testEffect } from "../lib/effect"
const testPty = process.platform === "win32" ? test.skip : test
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await resetDatabase()
}),
)
}),
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{ disableListenLog: true, disableLogger: true },
)
const effectIt = testEffect(
Layer.mergeAll(
testStateLayer,
Socket.layerWebSocketConstructorGlobal,
servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
),
),
)
function app() {
return Server.Default().app
}
function serverUrl() {
return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
}
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-opencode-directory", dir)
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("pty HttpApi bridge", () => {
test("serves available shell list through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(PtyPaths.shells, { headers: { "x-opencode-directory": tmp.path } })
expect(response.status).toBe(200)
expect(await response.json()).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: expect.any(String),
name: expect.any(String),
acceptable: expect.any(Boolean),
}),
]),
)
})
testPty("serves PTY JSON routes through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const list = await app().request(PtyPaths.list, { headers })
expect(list.status).toBe(200)
expect(await list.json()).toEqual([])
const created = await app().request(PtyPaths.create, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"], title: "demo" }),
})
expect(created.status).toBe(200)
const info = await created.json()
try {
expect(info).toMatchObject({ title: "demo", command: "/usr/bin/env", status: "running" })
const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
expect(found.status).toBe(200)
expect(await found.json()).toMatchObject({ id: info.id, title: "demo" })
const updated = await app().request(PtyPaths.update.replace(":ptyID", info.id), {
method: "PUT",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ title: "renamed", size: { cols: 80, rows: 24 } }),
})
expect(updated.status).toBe(200)
expect(await updated.json()).toMatchObject({ id: info.id, title: "renamed" })
} finally {
await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers })
}
const missing = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers })
expect(missing.status).toBe(404)
expect(await missing.json()).toEqual({
_tag: "PtyNotFoundError",
ptyID: info.id,
message: `PTY session not found: ${info.id}`,
})
const missingUpdate = await app().request(PtyPaths.update.replace(":ptyID", info.id), {
method: "PUT",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ title: "missing" }),
})
expect(missingUpdate.status).toBe(404)
expect(await missingUpdate.json()).toEqual({
_tag: "PtyNotFoundError",
ptyID: info.id,
message: `PTY session not found: ${info.id}`,
})
const missingRemove = await app().request(PtyPaths.remove.replace(":ptyID", info.id), { method: "DELETE", headers })
expect(missingRemove.status).toBe(404)
expect(await missingRemove.json()).toEqual({
_tag: "PtyNotFoundError",
ptyID: info.id,
message: `PTY session not found: ${info.id}`,
})
})
testPty("disposes PTY sessions with their legacy instance", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const created = await app().request(PtyPaths.create, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }),
})
expect(created.status).toBe(200)
await disposeAllInstances()
const list = await app().request(PtyPaths.list, { headers })
expect(list.status).toBe(200)
expect(await list.json()).toEqual([])
})
test("returns 404 for missing PTY websocket before upgrade", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(PtyPaths.connect.replace(":ptyID", PtyID.ascending()), {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
})
test("returns 404 for missing PTY websocket before decoding cursor query", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
})
test("returns typed not found errors for missing PTY HTTP resources", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const missingID = String(PtyID.ascending())
const expected = {
_tag: "PtyNotFoundError",
ptyID: missingID,
message: `PTY session not found: ${missingID}`,
}
const found = await app().request(PtyPaths.get.replace(":ptyID", missingID), { headers })
expect(found.status).toBe(404)
expect(await found.json()).toEqual(expected)
const updated = await app().request(PtyPaths.update.replace(":ptyID", missingID), {
method: "PUT",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ title: "missing" }),
})
expect(updated.status).toBe(404)
expect(await updated.json()).toEqual(expected)
const removed = await app().request(PtyPaths.remove.replace(":ptyID", missingID), { method: "DELETE", headers })
expect(removed.status).toBe(404)
expect(await removed.json()).toEqual(expected)
})
test("returns typed errors for PTY connect token failures", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
const missingID = String(PtyID.ascending())
const forbidden = await app().request(PtyPaths.connectToken.replace(":ptyID", missingID), {
method: "POST",
headers,
})
expect(forbidden.status).toBe(403)
expect(await forbidden.json()).toEqual({
_tag: "PtyForbiddenError",
message: "Invalid PTY connect token request",
})
const missing = await app().request(PtyPaths.connectToken.replace(":ptyID", missingID), {
method: "POST",
headers: {
...headers,
"x-opencode-ticket": "1",
},
})
expect(missing.status).toBe(404)
expect(await missing.json()).toEqual({
_tag: "PtyNotFoundError",
ptyID: missingID,
message: `PTY session not found: ${missingID}`,
})
})
;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)(
"serves PTY websocket output and input through Effect routes",
() =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
const created = yield* HttpClientRequest.post(PtyPaths.create).pipe(
directoryHeader(dir),
HttpClientRequest.bodyJson({ command: "/bin/cat", title: "websocket" }),
Effect.flatMap(HttpClient.execute),
)
expect(created.status).toBe(200)
const info = yield* Schema.decodeUnknownEffect(Pty.Info)(yield* created.json)
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl()).replace(/^http/, "ws")}${PtyPaths.connect.replace(":ptyID", info.id)}?cursor=-1&directory=${encodeURIComponent(dir)}`,
{ closeCodeIsError: () => false },
)
const messages = yield* Queue.unbounded<string>()
yield* socket
.runRaw((message) =>
Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)),
)
.pipe(Effect.catch(() => Effect.void))
.pipe(Effect.forkScoped)
const write = yield* socket.writer
const takeUntil = (expected: string, seen = ""): Effect.Effect<string, unknown> =>
Effect.gen(function* () {
const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds")))
if (next.includes(expected)) return next
return yield* takeUntil(expected, next)
})
yield* write("ping-route\n")
expect(yield* takeUntil("ping-route")).toContain("ping-route")
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
const removed = yield* HttpClientRequest.delete(PtyPaths.remove.replace(":ptyID", info.id)).pipe(
directoryHeader(dir),
HttpClient.execute,
)
expect(removed.status).toBe(200)
}),
)
})

View File

@@ -0,0 +1,317 @@
import { describe, expect, test } from "bun:test"
import { OpenApi } from "effect/unstable/httpapi"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
type Method = "get" | "post" | "put" | "delete" | "patch"
type OpenApiSchema = {
readonly $ref?: string
readonly anyOf?: ReadonlyArray<OpenApiSchema>
readonly type?: string
readonly enum?: readonly unknown[]
readonly properties?: Record<string, OpenApiSchema>
readonly required?: readonly string[]
}
type OpenApiResponse = {
readonly description?: string
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
}
type OpenApiOperation = {
readonly parameters?: ReadonlyArray<{
readonly name: string
readonly in: string
readonly required?: boolean
readonly schema?: { readonly type?: string }
}>
readonly responses?: Record<string, OpenApiResponse>
readonly requestBody?: { readonly required?: boolean }
readonly security?: unknown
}
type OpenApiPathItem = Partial<Record<Method, OpenApiOperation>>
type OpenApiSpec = {
readonly paths: Record<string, OpenApiPathItem>
readonly components: { readonly schemas: Record<string, OpenApiSchema> }
}
const methods = ["get", "post", "put", "delete", "patch"] as const
const allowedV2BuiltInEndpointErrors: string[] = []
function v2Operations(spec: OpenApiSpec) {
return Object.entries(spec.paths).flatMap(([path, item]) =>
path.startsWith("/api/")
? methods.flatMap((method) => {
const operation = item[method]
return operation ? [{ method, path, operation }] : []
})
: [],
)
}
function responseRef(response: OpenApiResponse | undefined) {
return response?.content?.["application/json"]?.schema?.$ref
}
function componentName(ref: string) {
return ref.replace("#/components/schemas/", "")
}
function componentNames(response: OpenApiResponse | undefined) {
const schema = response?.content?.["application/json"]?.schema
if (!schema) return []
return [
...new Set([schema, ...(schema.anyOf ?? [])].flatMap((item) => (item.$ref ? [componentName(item.$ref)] : []))),
]
}
function isBuiltInEndpointError(name: string) {
return name.startsWith("EffectHttpApiError") || name.startsWith("effect_HttpApiError_")
}
describe("PublicApi OpenAPI v2 errors", () => {
test("documents nested legacy global sync events", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const schema = spec.components.schemas.SyncEventSessionCreated
expect(schema?.required).toEqual(["type", "id", "syncEvent"])
expect(schema?.properties?.type?.enum).toEqual(["sync"])
expect(schema?.properties?.syncEvent).toMatchObject({
required: ["type", "id", "seq", "aggregateID", "data"],
properties: {
type: { enum: ["session.created.1"] },
id: { type: "string" },
seq: { type: "number" },
aggregateID: { type: "string" },
},
})
})
test("preserves /api auth responses", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const route of v2Operations(spec)) {
expect(route.operation.responses?.["401"], `${route.method.toUpperCase()} ${route.path}`).toBeDefined()
expect(route.operation.security, `${route.method.toUpperCase()} ${route.path}`).toEqual([])
}
})
test("documents references separately from filesystem routes", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const path of ["/api/fs/read/*", "/api/fs/list"]) {
expect(spec.paths[path]?.get?.parameters, path).not.toContainEqual(expect.objectContaining({ name: "reference" }))
}
expect(spec.paths["/api/reference"]?.get).toBeDefined()
})
test("preserves required request bodies for v2 mutations", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const path of [
"/api/session/{sessionID}/prompt",
"/api/session/{sessionID}/permission/{requestID}/reply",
"/api/session/{sessionID}/question/{requestID}/reply",
]) {
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
}
})
test("documents connector discovery and connection routes", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const [method, path] of [
["get", "/api/connector"],
["get", "/api/connector/{connectorID}"],
["post", "/api/connector/{connectorID}/connect/key"],
["post", "/api/connector/{connectorID}/connect/oauth"],
["get", "/api/connector/oauth/{attemptID}"],
["post", "/api/connector/oauth/{attemptID}/complete"],
["delete", "/api/connector/oauth/{attemptID}"],
] as const) {
expect(spec.paths[path]?.[method], `${method.toUpperCase()} ${path}`).toBeDefined()
}
for (const path of [
"/api/connector/{connectorID}/connect/key",
"/api/connector/{connectorID}/connect/oauth",
"/api/connector/oauth/{attemptID}/complete",
]) {
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
}
})
test("does not rewrite /api endpoint errors to legacy error components", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const refs = v2Operations(spec)
.flatMap((route) =>
Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
const ref = responseRef(response)
return ref ? [`${route.method.toUpperCase()} ${route.path} ${status} ${componentName(ref)}`] : []
}),
)
.filter((entry) => entry.endsWith(" BadRequestError") || entry.endsWith(" NotFoundError"))
expect(refs).toEqual([])
})
test("new /api endpoint errors cannot use built-in components without an explicit allowlist", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
const builtInEndpointErrors = v2Operations(spec)
.flatMap((route) =>
Object.entries(route.operation.responses ?? {}).flatMap(([status, response]) => {
if (status === "401") return []
const ref = responseRef(response)
if (!ref) return []
const name = componentName(ref)
return isBuiltInEndpointError(name) ? [`${route.method.toUpperCase()} ${route.path} ${status} ${name}`] : []
}),
)
.sort()
expect(builtInEndpointErrors).toEqual(allowedV2BuiltInEndpointErrors)
})
test("documents v2 provider and model catalog errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
expect(componentName(responseRef(spec.paths["/api/provider"]?.get?.responses?.["503"]) ?? "")).toBe(
"ServiceUnavailableError",
)
expect(componentName(responseRef(spec.paths["/api/model"]?.get?.responses?.["503"]) ?? "")).toBe(
"ServiceUnavailableError",
)
expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["404"]) ?? "")).toBe(
"ProviderNotFoundError",
)
expect(componentName(responseRef(spec.paths["/api/provider/{providerID}"]?.get?.responses?.["503"]) ?? "")).toBe(
"ServiceUnavailableError",
)
})
test("documents v2 session not-found errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const route of [
["post", "/api/session/{sessionID}/prompt"],
["post", "/api/session/{sessionID}/compact"],
["post", "/api/session/{sessionID}/wait"],
["get", "/api/session/{sessionID}/context"],
["get", "/api/session/{sessionID}/message"],
] as const) {
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toContain("SessionNotFoundError")
}
})
test("documents v2 unfinished session mutation errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const route of [
["post", "/api/session/{sessionID}/compact"],
["post", "/api/session/{sessionID}/wait"],
] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["503"]) ?? "")).toBe(
"ServiceUnavailableError",
)
}
})
test("documents v2 session read data errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const route of [
["get", "/api/session/{sessionID}/context"],
["get", "/api/session/{sessionID}/message"],
] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["500"]) ?? "")).toMatch(
/^UnknownError\d*$/,
)
}
})
test("documents session busy errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const route of [
["post", "/session/{sessionID}/shell"],
["post", "/session/{sessionID}/revert"],
["post", "/session/{sessionID}/unrevert"],
["delete", "/session/{sessionID}/message/{messageID}"],
] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["409"]) ?? "")).toBe(
"SessionBusyError",
)
}
})
test("documents permission and question not-found errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
expect(
componentName(responseRef(spec.paths["/permission/{requestID}/reply"]?.post?.responses?.["404"]) ?? ""),
).toBe("PermissionNotFoundError")
for (const route of [
["post", "/question/{requestID}/reply"],
["post", "/question/{requestID}/reject"],
] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
"QuestionNotFoundError",
)
}
for (const route of [
["post", "/api/session/{sessionID}/question/{requestID}/reply"],
["post", "/api/session/{sessionID}/question/{requestID}/reject"],
] as const) {
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
"QuestionNotFoundError",
"SessionNotFoundError",
])
}
})
test("documents MCP server not-found errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const route of [
["post", "/mcp/{name}/auth"],
["post", "/mcp/{name}/auth/authenticate"],
["post", "/mcp/{name}/auth/callback"],
["delete", "/mcp/{name}/auth"],
["post", "/mcp/{name}/connect"],
["post", "/mcp/{name}/disconnect"],
] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
"McpServerNotFoundError",
)
}
})
test("documents PTY resource and ticket errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
for (const route of [
["get", "/pty/{ptyID}"],
["put", "/pty/{ptyID}"],
["delete", "/pty/{ptyID}"],
["post", "/pty/{ptyID}/connect-token"],
] as const) {
expect(componentName(responseRef(spec.paths[route[1]]?.[route[0]]?.responses?.["404"]) ?? "")).toBe(
"PtyNotFoundError",
)
}
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
"PtyForbiddenError",
)
expect(
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
?.filter((parameter) => parameter.in === "query")
.map((parameter) => parameter.name),
).toEqual(["directory", "workspace", "cursor", "ticket"])
})
test("documents project not-found errors", () => {
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
expect(componentName(responseRef(spec.paths["/project/{projectID}"]?.patch?.responses?.["404"]) ?? "")).toBe(
"ProjectNotFoundError",
)
})
})

View File

@@ -0,0 +1,330 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { OpenApi } from "effect/unstable/httpapi"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../src/server/server"
import { SessionID } from "../../src/session/schema"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import {
FilePaths,
FileQuery,
FindFileQuery,
FindTextQuery,
} from "../../src/server/routes/instance/httpapi/groups/file"
import {
ExperimentalPaths,
SessionListQuery as ExperimentalSessionListQuery,
ToolListQuery,
} from "../../src/server/routes/instance/httpapi/groups/experimental"
import { InstancePaths, VcsDiffQuery } from "../../src/server/routes/instance/httpapi/groups/instance"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
ListQuery as SessionListQuery,
MessagesQuery,
SessionPaths,
} from "../../src/server/routes/instance/httpapi/groups/session"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { SessionMessagesQuery } from "@opencode-ai/server/groups/message"
import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
type Method = "get" | "post" | "put" | "delete" | "patch"
type QuerySchema = { readonly fields: Record<string, unknown> }
type OpenApiSchema = {
readonly anyOf?: readonly OpenApiSchema[]
readonly enum?: readonly string[]
readonly maximum?: number
readonly minimum?: number
readonly pattern?: string
readonly type?: string
}
type OpenApiParameter = { readonly name: string; readonly in: string; readonly schema?: OpenApiSchema }
type OpenApiOperation = { readonly parameters?: readonly OpenApiParameter[] }
const openApiDriftRoutes = [
{ method: "get", path: SessionPaths.list, query: SessionListQuery },
{ method: "get", path: SessionPaths.messages, query: MessagesQuery },
{ method: "get", path: FilePaths.findFile, query: FindFileQuery },
{ method: "get", path: FilePaths.findText, query: FindTextQuery },
{ method: "get", path: FilePaths.list, query: FileQuery },
{ method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
{ method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
{ method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
{ method: "get", path: "/api/session/:sessionID/message", query: SessionMessagesQuery },
] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
const numericSdkQueryParams = [
{ method: "get", path: ExperimentalPaths.session, name: "start", schema: { type: "number" } },
{ method: "get", path: ExperimentalPaths.session, name: "cursor", schema: { type: "number" } },
{ method: "get", path: ExperimentalPaths.session, name: "limit", schema: { type: "number" } },
{ method: "get", path: FilePaths.findFile, name: "limit", schema: { type: "integer", minimum: 1, maximum: 200 } },
{ method: "get", path: SessionPaths.list, name: "start", schema: { type: "number" } },
{ method: "get", path: SessionPaths.list, name: "limit", schema: { type: "number" } },
{
method: "get",
path: SessionPaths.messages,
name: "limit",
schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
},
{ method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
const booleanSdkQueryParams = [
{ method: "get", path: ExperimentalPaths.session, name: "roots" },
{ method: "get", path: ExperimentalPaths.session, name: "archived" },
{ method: "get", path: SessionPaths.list, name: "roots" },
] satisfies Array<{ method: Method; path: string; name: string }>
const queryParamPatterns = [
{ method: "get", path: SessionPaths.diff, name: "messageID", pattern: "^msg" },
] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
const pathParamPatterns = [
{ method: "get", path: SessionPaths.get, name: "sessionID", pattern: "^ses" },
{ method: "get", path: SessionPaths.message, name: "messageID", pattern: "^msg" },
{ method: "patch", path: SessionPaths.updatePart, name: "partID", pattern: "^prt" },
{ method: "post", path: SessionPaths.permissions, name: "permissionID", pattern: "^per" },
{ method: "post", path: "/permission/:requestID/reply", name: "requestID", pattern: "^per" },
{ method: "post", path: "/question/:requestID/reply", name: "requestID", pattern: "^que" },
{ method: "put", path: PtyPaths.update, name: "ptyID", pattern: "^pty" },
{ method: "delete", path: WorkspacePaths.remove, name: "id", pattern: "^wrk" },
] satisfies Array<{ method: Method; path: string; name: string; pattern: string }>
function app() {
return Server.Default().app
}
function request(url: string, init?: RequestInit) {
return Effect.promise(async () => app().request(url, init))
}
function withTmp<A, E, R>(
options: Parameters<typeof tmpdir>[0],
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
) {
return Effect.acquireRelease(
Effect.promise(() => tmpdir(options)),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap(fn))
}
function openApiPath(path: string) {
return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
}
function queryParameters(operation: OpenApiOperation | undefined) {
return (operation?.parameters ?? []).filter((param) => param.in === "query").map((param) => param.name)
}
function queryParameter(operation: OpenApiOperation | undefined, name: string) {
return (operation?.parameters ?? []).find((param) => param.in === "query" && param.name === name)
}
function pathParameter(operation: OpenApiOperation | undefined, name: string) {
return (operation?.parameters ?? []).find((param) => param.in === "path" && param.name === name)
}
function assertAdvertisedQueryParamsAreRuntimeFields(input: {
readonly method: Method
readonly operation: OpenApiOperation | undefined
readonly path: string
readonly query: QuerySchema
}) {
const runtimeFields = new Set(Object.keys(input.query.fields))
const advertisedOnly = queryParameters(input.operation).filter((name) => !runtimeFields.has(name))
expect(
advertisedOnly,
`${input.method.toUpperCase()} ${input.path} advertises query params not accepted by runtime schema`,
).toEqual([])
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
})
// Regression for the "OpenAPI advertises ?directory&workspace, runtime
// rejects them" drift class. Each affected route must accept both params
// without 400.
describe("httpapi query schema drift", () => {
const routingParams = (dir: string) =>
`directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}`
const expectNotSchemaRejection = (status: number, url: string) => {
expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
}
it.effect(
"boolean query schema accepts only true and false strings",
Effect.sync(() => {
const decode = Schema.decodeUnknownSync(QueryBoolean)
const encode = Schema.encodeUnknownSync(QueryBoolean)
expect(decode("true")).toBe(true)
expect(decode("false")).toBe(false)
expect(encode(true)).toBe("true")
expect(encode(false)).toBe("false")
for (const input of ["1", "yes", "True", "", true, false]) {
expect(() => decode(input)).toThrow()
}
}),
)
it.effect(
"OpenAPI query params are declared by runtime query schemas",
Effect.sync(() => {
const spec = OpenApi.fromApi(PublicApi)
for (const route of openApiDriftRoutes) {
assertAdvertisedQueryParamsAreRuntimeFields({
...route,
operation: spec.paths[openApiPath(route.path)]?.[route.method],
})
}
}),
)
it.effect(
"OpenAPI query and path schemas preserve compatibility metadata",
Effect.sync(() => {
const spec = OpenApi.fromApi(PublicApi)
for (const expected of numericSdkQueryParams) {
expect(
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
).toEqual(expected.schema)
}
for (const expected of booleanSdkQueryParams) {
expect(
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
).toEqual(QueryBooleanOpenApi)
}
for (const expected of queryParamPatterns) {
expect(
queryParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
).toEqual({ type: "string", pattern: expected.pattern })
}
for (const expected of pathParamPatterns) {
expect(
pathParameter(spec.paths[openApiPath(expected.path)]?.[expected.method], expected.name)?.schema,
`${expected.method.toUpperCase()} ${expected.path} ${expected.name}`,
).toEqual({ type: "string", pattern: expected.pattern })
}
}),
)
it.effect(
"drift assertion catches spec-only workspace query params",
Effect.sync(() => {
expect(() =>
assertAdvertisedQueryParamsAreRuntimeFields({
method: "get",
operation: {
parameters: [
{ name: "directory", in: "query" },
{ name: "workspace", in: "query" },
],
},
path: "/fixture",
query: Schema.Struct({}),
}),
).toThrow("advertises query params not accepted by runtime schema")
}),
)
it.live(
"session list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/session?${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"session messages accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"file find/file accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/find/file?query=foo&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"file find/text accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/find?pattern=foo&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"file read accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/file?path=foo&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"experimental session list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/experimental/session?${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"experimental tool list accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
it.live(
"vcs diff accepts directory and workspace",
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}`
const response = yield* request(url)
expectNotSchemaRejection(response.status, url)
}),
),
)
})

View File

@@ -0,0 +1,62 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Server } from "../../src/server/server"
import { Global } from "@opencode-ai/core/global"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("reference HttpApi", () => {
test("lists usable references resolved in the server workspace", async () => {
await using tmp = await tmpdir({
config: {
formatter: false,
lsp: false,
references: {
docs: "./docs",
effect: { repository: "Effect-TS/effect", branch: "main" },
bad: "not-a-repo",
},
},
})
const response = await Server.Default().app.request("/api/reference", {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = await response.json()
expect(body).toMatchObject({ location: { directory: tmp.path } })
expect(body.data).toEqual([
{
name: "docs",
path: path.join(tmp.path, "docs"),
description: null,
hidden: null,
source: {
type: "local",
path: path.join(tmp.path, "docs"),
description: null,
hidden: null,
},
},
{
name: "effect",
path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"),
description: null,
hidden: null,
source: {
type: "git",
repository: "Effect-TS/effect",
branch: "main",
description: null,
hidden: null,
},
},
])
})
})

View File

@@ -0,0 +1,165 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { Session } from "@/session/session"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { MessageID, PartID } from "../../src/session/schema"
import { PartTable } from "@opencode-ai/core/session/sql"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
const text = (response: HttpClientResponse.HttpClientResponse) => response.text
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const seedCorruptStepFinishPart = Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
// that broke the user's session in the OMO/Windows bug.
const { db } = yield* Database.Service
yield* db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
} as never, // drizzle's .set() can't narrow the discriminated union
})
.where(eq(PartTable.id, partID))
.run()
.pipe(Effect.orDie)
return info.id
})
describe("schema-rejection wire shape", () => {
it.instance(
"Payload schema rejection returns NamedError-shaped JSON, not empty",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
})
const body = yield* text(res)
expect(res.status).toBe(400)
expect(res.headers["content-type"] ?? "").toContain("application/json")
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({
name: "BadRequest",
data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
})
expect(parsed.data.message).toEqual(expect.any(String))
expect(parsed.data.message.length).toBeGreaterThan(0)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"Query schema rejection returns NamedError-shaped JSON",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
// /find/file?limit=999999 violates the limit constraint check.
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
const res = yield* requestInDirectory(url, test.directory)
const body = yield* text(res)
expect(res.status).toBe(400)
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"v2 query schema rejection returns InvalidRequestError JSON",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const res = yield* requestInDirectory("/api/session?limit=0", test.directory)
const parsed = JSON.parse(yield* text(res))
expect(res.status).toBe(400)
expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" })
expect(parsed.message).toEqual(expect.any(String))
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"rejected request body never echoes back unbounded — message is capped",
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter
// dumps the rejected `actual` verbatim. A multi-MB invalid array would
// become a multi-MB 400 response and log line. Cap kicks in around 1KB.
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const huge = "X".repeat(50_000)
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ aggregate: huge }),
})
const body = yield* text(res)
expect(res.status).toBe(400)
// 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
expect(body.length).toBeLessThan(2 * 1024)
const parsed = JSON.parse(body)
expect(parsed.data.message).not.toContain(huge)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const sessionID = yield* seedCorruptStepFinishPart
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
const res = yield* requestInDirectory(url, test.directory)
const body = yield* text(res)
expect(res.status).toBe(400)
expect(res.headers["content-type"] ?? "").toContain("application/json")
const parsed = JSON.parse(body)
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
// Field path in data.message — what made this PR worth shipping.
expect(parsed.data.message).toMatch(/output/)
}),
{ config: { formatter: false, lsp: false } },
)
})

View File

@@ -0,0 +1,909 @@
import { afterEach, describe, expect } from "bun:test"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Deferred, Effect, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { HttpServer } from "effect/unstable/http"
import { ChildProcessSpawner } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { validateSession } from "../../src/cli/tui/validate-session"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import type { Config } from "@/config/config"
import { Session as SessionNs } from "@/session/session"
import { errorMessage } from "../../src/util/error"
import { TestLLMServer } from "../lib/llm-server"
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"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { Database } from "@opencode-ai/core/database/database"
import { httpApiLayer } from "./httpapi-layer"
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const it = testEffect(
Layer.mergeAll(
FSUtil.defaultLayer,
CrossSpawnSpawner.defaultLayer,
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
Database.defaultLayer,
httpApiLayer,
),
)
const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
type ServerPath = "default" | "raw"
type Sdk = ReturnType<typeof createOpencodeClient>
type SdkResult = { response: Response; data?: unknown; error?: unknown }
type Captured = { status: number; data?: unknown; error?: unknown }
type ProjectFixture = { sdk: Sdk; directory: string }
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
type TestServices =
| FSUtil.Service
| ChildProcessSpawner.ChildProcessSpawner
| InstanceStore.Service
| HttpServer.HttpServer
type TestScope = Scope.Scope | TestServices
function client(
serverPath: ServerPath,
directory?: string,
input?: {
password?: string
username?: string
headers?: Record<string, string>
workspaceID?: string
onRequest?: (request: Request) => void
},
) {
return serverFetch(serverPath, input).pipe(
Effect.map((fetch) =>
createOpencodeClient({
baseUrl: "http://localhost",
directory,
experimental_workspaceID: input?.workspaceID,
headers: input?.headers,
fetch,
}),
),
)
}
function serverFetch(
serverPath: ServerPath,
input?: { password?: string; username?: string; onRequest?: (request: Request) => void },
) {
return HttpServer.HttpServer.use((server) =>
Effect.sync(() => {
void serverPath
Flag.OPENCODE_SERVER_PASSWORD = input?.password
Flag.OPENCODE_SERVER_USERNAME = input?.username
const baseUrl = HttpServer.formatAddress(server.address)
return Object.assign(
async (request: RequestInfo | URL, init?: RequestInit) => {
const source = request instanceof Request ? request : new Request(request, init)
input?.onRequest?.(source)
const url = new URL(source.url)
return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source))
},
{ preconnect: globalThis.fetch.preconnect },
) satisfies typeof globalThis.fetch
}),
)
}
function authorization(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
function call<T>(request: () => Promise<T>) {
return Effect.promise(request)
}
function capture(request: () => Promise<SdkResult>) {
return call(request).pipe(
Effect.map((result) => ({
status: result.response.status,
data: result.data,
error: result.error,
})),
)
}
function captureThrown(request: () => Promise<unknown>) {
return call(async () => {
try {
await request()
} catch (error) {
return error
}
})
}
function expectStatus(request: () => Promise<{ response: Response }>, status: number) {
return call(request).pipe(
Effect.tap((result) => Effect.sync(() => expect(result.response.status).toBe(status))),
Effect.asVoid,
)
}
function firstEvent(open: (signal: AbortSignal) => Promise<{ stream: AsyncIterator<unknown> }>) {
return Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(controller) => Effect.sync(() => controller.abort()),
).pipe(
Effect.flatMap((controller) =>
Effect.acquireRelease(
call(() => open(controller.signal)),
(events) => call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
).pipe(
Effect.flatMap((events) =>
call(() => events.stream.next()).pipe(
Effect.timeoutOrElse({
duration: "1 second",
orElse: () => Effect.fail(new Error("timed out waiting for SDK event")),
}),
),
),
Effect.map((result) => result.value),
),
),
)
}
function record(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
}
function array(value: unknown) {
return Array.isArray(value) ? value : []
}
function statuses(input: Record<string, Captured>) {
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, value.status]))
}
function firstPartText(value: unknown) {
return record(array(record(value).parts)[0]).text
}
function sessionTitles(value: unknown) {
return array(value)
.map((item) => record(item).title)
.filter((title): title is string => typeof title === "string")
.sort()
}
function resetState() {
return Effect.promise(async () => {
await disposeAllInstances()
await resetDatabase()
})
}
function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, TestScope>) {
it.live(name, effect)
}
function httpapiInstance<A, E>(
name: string,
options: {
serverPath: ServerPath
git?: boolean
config?: Partial<ConfigV1.Info>
setup?: (dir: string) => Effect.Effect<void, E, TestServices>
},
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
) {
it.instance(
name,
Effect.gen(function* () {
const instance = yield* TestInstance
yield* options.setup?.(instance.directory) ?? Effect.void
return yield* run({ sdk: yield* client(options.serverPath, instance.directory), directory: instance.directory })
}),
{ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } },
)
}
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, TestScope>) {
it.live(name, scenario("raw"))
}
function withProject<A, E, E2 = never>(
serverPath: ServerPath,
options: {
git?: boolean
config?: Partial<ConfigV1.Info>
setup?: (dir: string) => Effect.Effect<void, E2, TestServices>
},
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
) {
return Effect.gen(function* () {
const directory = yield* tmpdirScoped({
git: options.git ?? false,
config: { formatter: false, lsp: false, ...options.config },
})
yield* options.setup?.(directory) ?? Effect.void
return yield* run({ sdk: yield* client(serverPath, directory), directory })
})
}
function withStandardProject<A, E>(
serverPath: ServerPath,
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
) {
return withProject(serverPath, { setup: writeStandardFiles }, run)
}
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 }))
}).pipe(Effect.provide(TestLLMServer.layer))
}
function withFakeLlmProject<A, E>(
serverPath: ServerPath,
options: { setup?: (dir: string) => Effect.Effect<void, E, TestServices> },
run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>,
) {
return Effect.gen(function* () {
const llm = yield* TestLLMServer
return yield* withProject(
serverPath,
{
config: testProviderConfig(llm.url),
setup: options.setup,
},
(input) => run({ ...input, llm }),
)
}).pipe(Effect.provide(TestLLMServer.layer))
}
function writeStandardFiles(dir: string) {
return FSUtil.Service.use((fs) =>
Effect.all([
fs.writeWithDirs(path.join(dir, "hello.txt"), "hello"),
fs.writeWithDirs(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n"),
]).pipe(Effect.asVoid),
)
}
function writeProjectSkill(dir: string) {
return FSUtil.Service.use((fs) =>
fs.writeWithDirs(
path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
`---
name: project-rest-skill
description: A project skill visible to REST API prompts.
---
# Project REST Skill
`,
),
)
}
function seedMessage(directory: string, sessionID: string) {
const id = SessionID.make(sessionID)
return InstanceStore.Service.use((store) =>
store.provide(
{ directory },
SessionNs.Service.use((svc) =>
Effect.gen(function* () {
const message = yield* svc.updateMessage({
id: MessageID.ascending(),
sessionID: id,
role: "user",
time: { created: Date.now() },
agent: "test",
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
tools: {},
} satisfies SessionV1.User)
const part = yield* svc.updatePart({
id: PartID.ascending(),
sessionID: id,
messageID: message.id,
type: "text",
text: "seeded message",
})
return { message, part }
}),
).pipe(Effect.provide(SessionNs.defaultLayer)),
),
)
}
afterEach(async () => {
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
await disposeAllInstances()
await resetDatabase()
})
describe("HttpApi SDK", () => {
httpapi(
"uses the generated SDK for global and control routes",
Effect.gen(function* () {
const sdk = yield* client("raw")
const health = yield* call(() => sdk.global.health())
const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }))
expect(health.response.status).toBe(200)
expect(health.data).toMatchObject({ healthy: true })
expect(yield* firstEvent((signal) => sdk.global.event({ signal }))).toMatchObject({
payload: { type: "server.connected" },
})
expect(log.response.status).toBe(200)
expect(log.data).toBe(true)
yield* expectStatus(() => sdk.auth.set({ providerID: "test" }), 400)
}),
)
httpapiInstance(
"uses the generated SDK for safe instance routes",
{ serverPath: "raw", git: false, setup: writeStandardFiles },
({ sdk }) =>
Effect.gen(function* () {
const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
const session = yield* call(() => sdk.session.create({ title: "sdk" }))
const listed = yield* call(() => sdk.session.list({ roots: true, limit: 10 }))
expect(file.response.status).toBe(200)
expect(file.data).toMatchObject({ content: "hello" })
expect(session.response.status).toBe(200)
expect(session.data).toMatchObject({ title: "sdk" })
expect(listed.response.status).toBe(200)
expect(listed.data?.map((item) => item.id)).toContain(session.data?.id)
yield* Effect.all([
expectStatus(() => sdk.project.current(), 200),
expectStatus(() => sdk.config.get(), 200),
expectStatus(() => sdk.config.providers(), 200),
expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200),
])
}),
)
httpapi(
"routes configured SDK directory and workspace for v2 location GETs",
withProject("raw", { setup: writeStandardFiles }, ({ directory }) =>
Effect.gen(function* () {
const workspaceID = "wrk_sdk"
let request: Request | undefined
const sdk = yield* client("raw", directory, {
workspaceID,
onRequest: (value) => (request = value),
})
const found = yield* call(() => sdk.v2.fs.find({ query: "hello", type: "file" }))
const url = new URL(request!.url)
expect(found.response.status).toBe(200)
expect(found.data).toMatchObject({ data: [{ path: "hello.txt", type: "file" }] })
expect(url.searchParams.get("directory")).toBe(directory)
expect(url.searchParams.get("workspace")).toBe(workspaceID)
expect(url.searchParams.get("location[directory]")).toBe(directory)
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
expect(request!.headers.has("x-opencode-directory")).toBe(false)
expect(request!.headers.has("x-opencode-workspace")).toBe(false)
}),
),
)
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
Effect.gen(function* () {
const sdk = yield* client(serverPath)
const health = yield* capture(() => sdk.global.health())
const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
return {
statuses: statuses({ health, log, invalidAuth }),
health: record(health.data).healthy,
log: log.data,
}
}),
)
serverPathParity("matches generated SDK global event stream", (serverPath) =>
Effect.gen(function* () {
const sdk = yield* client(serverPath)
const event = yield* firstEvent((signal) => sdk.global.event({ signal }))
return { type: record(record(event).payload).type }
}),
)
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
firstEvent((signal) => sdk.event.subscribe(undefined, { signal })).pipe(
Effect.map((event) => ({ type: record(record(event).payload).type })),
),
),
)
serverPathParity("matches generated SDK missing session errors", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const sessionID = "ses_missing"
const expected = {
name: "NotFoundError",
data: { message: `Session not found: ${sessionID}` },
}
const missing = yield* capture(() => sdk.session.get({ sessionID }))
const thrown = yield* captureThrown(() => sdk.session.get({ sessionID }, { throwOnError: true }))
// Result-tuple path: error body is preserved as-is so existing
// consumers reading `result.error.name` / `JSON.stringify(error)`
// keep working byte-for-byte.
expect(missing.error).toEqual(expected)
// throwOnError path: SDK wraps the body in a real Error with the
// server's message, with the original parsed body preserved under
// `.cause.body`.
expect(thrown).toBeInstanceOf(Error)
expect((thrown as Error).message).toBe(expected.data.message)
expect(((thrown as Error).cause as { body: unknown }).body).toEqual(expected)
return {
status: missing.status,
error: missing.error,
thrown,
}
}),
),
)
serverPathParity("formats missing session validation errors for -s", (serverPath) =>
withStandardProject(serverPath, ({ directory }) =>
Effect.gen(function* () {
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
const fetch = yield* serverFetch(serverPath)
const thrown = yield* captureThrown(() =>
validateSession({
url: "http://localhost",
directory,
sessionID,
fetch,
}),
)
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
return errorMessage(thrown)
}),
),
)
httpapiInstance(
"uses generated SDK basic auth behavior",
{ serverPath: "raw", setup: writeStandardFiles },
({ directory }) =>
Effect.gen(function* () {
const missingSdk = yield* client("raw", directory, { password: "secret" })
const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" }))
const badSdk = yield* client("raw", directory, {
password: "secret",
headers: { authorization: authorization("opencode", "wrong") },
})
const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" }))
const goodSdk = yield* client("raw", directory, {
password: "secret",
headers: { authorization: authorization("opencode", "secret") },
})
const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" }))
return {
statuses: statuses({ missing, bad, good }),
content: record(good.data).content,
}
}),
)
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
withProject(serverPath, { git: true, setup: writeStandardFiles }, ({ sdk, directory }) =>
Effect.gen(function* () {
const project = yield* capture(() => sdk.project.current())
const projects = yield* capture(() => sdk.project.list())
const paths = yield* capture(() => sdk.path.get())
const config = yield* capture(() => sdk.config.get())
const providers = yield* capture(() => sdk.config.providers())
const file = yield* capture(() => sdk.file.read({ path: "hello.txt" }))
const files = yield* capture(() => sdk.file.list({ path: "." }))
const fileStatus = yield* capture(() => sdk.file.status())
const findFiles = yield* capture(() => sdk.find.files({ query: "hello", limit: 10 }))
const findText = yield* capture(() => sdk.find.text({ pattern: "sdk-parity" }))
const agents = yield* capture(() => sdk.app.agents())
const skills = yield* capture(() => sdk.app.skills())
const tools = yield* capture(() => sdk.tool.ids())
const vcs = yield* capture(() => sdk.vcs.get())
const formatter = yield* capture(() => sdk.formatter.status())
const lsp = yield* capture(() => sdk.lsp.status())
return {
statuses: statuses({
project,
projects,
paths,
config,
providers,
file,
files,
fileStatus,
findFiles,
findText,
agents,
skills,
tools,
vcs,
formatter,
lsp,
}),
project: { worktreeSelected: record(project.data).worktree === directory },
paths: { directorySelected: record(paths.data).directory === directory },
file: record(file.data).content,
hasProject: array(projects.data).length > 0,
foundFile: JSON.stringify(findFiles.data).includes("hello.txt"),
foundText: JSON.stringify(findText.data ?? null).includes("sdk-parity"),
listedFile: JSON.stringify(files.data).includes("hello.txt"),
vcs: { hasBranch: typeof record(vcs.data).branch === "string" },
}
}),
),
)
serverPathParity("matches generated SDK session lifecycle routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const parent = yield* capture(() => sdk.session.create({ title: "parent" }))
const parentID = String(record(parent.data).id)
const child = yield* capture(() => sdk.session.create({ title: "child", parentID }))
const childID = String(record(child.data).id)
const get = yield* capture(() => sdk.session.get({ sessionID: parentID }))
const update = yield* capture(() => sdk.session.update({ sessionID: parentID, title: "renamed" }))
const roots = yield* capture(() => sdk.session.list({ roots: true, limit: 10 }))
const all = yield* capture(() => sdk.session.list({ roots: false, limit: 10 }))
const children = yield* capture(() => sdk.session.children({ sessionID: parentID }))
const todo = yield* capture(() => sdk.session.todo({ sessionID: parentID }))
const status = yield* capture(() => sdk.session.status())
const messages = yield* capture(() => sdk.session.messages({ sessionID: parentID }))
const missingGet = yield* capture(() => sdk.session.get({ sessionID: "ses_missing" }))
const missingMessages = yield* capture(() => sdk.session.messages({ sessionID: "ses_missing", limit: 2 }))
const invalidCursor = yield* capture(() =>
sdk.session.messages({ sessionID: parentID, limit: 2, before: "bad" }),
)
const deleted = yield* capture(() => sdk.session.delete({ sessionID: childID }))
const getDeleted = yield* capture(() => sdk.session.get({ sessionID: childID }))
return {
statuses: statuses({
parent,
child,
get,
update,
roots,
all,
children,
todo,
status,
messages,
missingGet,
missingMessages,
invalidCursor,
deleted,
getDeleted,
}),
getTitle: record(get.data).title,
updatedTitle: record(update.data).title,
rootTitles: sessionTitles(roots.data),
allTitles: sessionTitles(all.data),
childCount: array(children.data).length,
todoCount: array(todo.data).length,
messageCount: array(messages.data).length,
}
}),
),
)
serverPathParity("matches generated SDK session message and part routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk, directory }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "messages" }))
const sessionID = String(record(session.data).id)
const seeded = yield* seedMessage(directory, sessionID)
const list = yield* capture(() => sdk.session.messages({ sessionID }))
const page = yield* capture(() => sdk.session.messages({ sessionID, limit: 1 }))
const message = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
const partUpdate = yield* capture(() =>
sdk.part.update({
sessionID,
messageID: seeded.message.id,
partID: seeded.part.id,
part: { ...seeded.part, text: "updated message" } as NonNullable<
Parameters<Sdk["part"]["update"]>[0]["part"]
>,
}),
)
const updated = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
const partDelete = yield* capture(() =>
sdk.part.delete({ sessionID, messageID: seeded.message.id, partID: seeded.part.id }),
)
const withoutPart = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
const deleteMessage = yield* capture(() =>
sdk.session.deleteMessage({ sessionID, messageID: seeded.message.id }),
)
const missingMessage = yield* capture(() => sdk.session.message({ sessionID, messageID: seeded.message.id }))
return {
statuses: statuses({
session,
list,
page,
message,
partUpdate,
updated,
partDelete,
withoutPart,
deleteMessage,
missingMessage,
}),
listCount: array(list.data).length,
pageCount: array(page.data).length,
initialText: firstPartText(message.data),
updatedText: firstPartText(updated.data),
partCountAfterDelete: array(record(withoutPart.data).parts).length,
}
}),
),
)
// Regression: EventV2 must publish on the same ProjectBus the /event handler
// subscribes to, AND the /event stream must forward handler ALS/context into the
// body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run →
// bus.publish → SDK subscriber path. Goes red if either the publisher uses a
// different bus instance (Bug 2 / pre-#27825) or the stream loses context (Bug 1 /
// pre-#27425).
serverPathParity("streams sync-backed part updates to /event subscribers", (serverPath) =>
withStandardProject(serverPath, ({ sdk, directory }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "sync-backed part event" }))
const sessionID = String(record(session.data).id)
const seeded = yield* seedMessage(directory, sessionID)
const controller = new AbortController()
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
const events = yield* call(() => sdk.event.subscribe(undefined, { signal: controller.signal }))
yield* Effect.addFinalizer(() =>
call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
)
const ready = yield* Deferred.make<void>()
const received = yield* Deferred.make<unknown>()
yield* call(async () => {
for await (const event of events.stream) {
const payload = record(event).payload ?? event
const type = record(payload).type
if (type === "server.connected") {
Deferred.doneUnsafe(ready, Effect.void)
continue
}
if (type === MessageV2.Event.PartUpdated.type) {
Deferred.doneUnsafe(received, Effect.succeed(payload))
return
}
}
}).pipe(Effect.forkScoped)
yield* awaitWithTimeout(Deferred.await(ready), "timed out waiting for /event server.connected", "2 seconds")
const updated = yield* capture(() =>
sdk.part.update({
sessionID,
messageID: seeded.message.id,
partID: seeded.part.id,
part: { ...seeded.part, text: "updated via sync" } as NonNullable<
Parameters<Sdk["part"]["update"]>[0]["part"]
>,
}),
)
expect(updated.status).toBe(200)
const event = yield* awaitWithTimeout(
Deferred.await(received),
"timed out waiting for message.part.updated bus payload over /event",
"5 seconds",
)
const properties = record(record(event).properties)
expect(record(properties.part)).toMatchObject({ id: seeded.part.id, type: "text" })
return { type: record(event).type, partType: record(properties.part).type }
}),
),
)
serverPathParity("matches generated SDK prompt no-reply routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "prompt" }))
const sessionID = String(record(session.data).id)
const prompt = yield* capture(() =>
sdk.session.prompt({
sessionID,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}),
)
const asyncPrompt = yield* capture(() =>
sdk.session.promptAsync({
sessionID,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "async hello" }],
}),
)
const messages = yield* capture(() => sdk.session.messages({ sessionID }))
return {
statuses: statuses({ session, prompt, asyncPrompt, messages }),
promptRole: record(record(prompt.data).info).role,
messageCount: array(messages.data).length,
messageTexts: array(messages.data)
.flatMap((item) => array(record(item).parts))
.map((part) => record(part).text)
.filter((text): text is string => typeof text === "string")
.sort(),
}
}),
),
)
serverPathParity("matches generated SDK prompt streaming through fake LLM", (serverPath) =>
withFakeLlm(serverPath, ({ sdk, llm }) =>
Effect.gen(function* () {
yield* llm.text("fake world", { usage: { input: 11, output: 7 } })
const session = yield* capture(() =>
sdk.session.create({
title: "llm prompt",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const sessionID = String(record(session.data).id)
const prompt = yield* capture(() =>
sdk.session.prompt({
sessionID,
agent: "build",
model: { providerID: "test", modelID: "test-model" },
parts: [{ type: "text", text: "hello llm" }],
}),
)
const messages = yield* capture(() => sdk.session.messages({ sessionID }))
const inputs = yield* llm.inputs
return {
statuses: statuses({ session, prompt, messages }),
calls: inputs.length,
requestedModel: inputs[0]?.model,
responseText: JSON.stringify(prompt.data).includes("fake world"),
persistedText: JSON.stringify(messages.data).includes("fake world"),
userText: JSON.stringify(messages.data).includes("hello llm"),
}
}),
),
)
httpapi(
"includes project skills in REST API prompt context",
withFakeLlmProject("default", { setup: writeProjectSkill }, ({ sdk, llm }) =>
Effect.gen(function* () {
yield* llm.text("skill context ok", { usage: { input: 11, output: 7 } })
const session = yield* capture(() =>
sdk.session.create({
title: "project skill prompt",
permission: [{ permission: "*", pattern: "*", action: "allow" }],
}),
)
const sessionID = String(record(session.data).id)
const prompt = yield* capture(() =>
sdk.session.prompt({
sessionID,
agent: "build",
model: { providerID: "test", modelID: "test-model" },
parts: [{ type: "text", text: "hello skill context" }],
}),
)
const inputs = yield* llm.inputs
expect(session.status).toBe(200)
expect(prompt.status).toBe(200)
expect(JSON.stringify(inputs[0])).toContain("project-rest-skill")
}),
),
)
serverPathParity("matches generated SDK TUI validation and command routes", (serverPath) =>
withStandardProject(serverPath, ({ sdk }) =>
Effect.gen(function* () {
const session = yield* capture(() => sdk.session.create({ title: "tui" }))
const sessionID = String(record(session.data).id)
const appendPrompt = yield* capture(() => sdk.tui.appendPrompt({ text: "hello" }))
const openHelp = yield* capture(() => sdk.tui.openHelp())
const openSessions = yield* capture(() => sdk.tui.openSessions())
const openThemes = yield* capture(() => sdk.tui.openThemes())
const openModels = yield* capture(() => sdk.tui.openModels())
const submitPrompt = yield* capture(() => sdk.tui.submitPrompt())
const clearPrompt = yield* capture(() => sdk.tui.clearPrompt())
const executeCommand = yield* capture(() => sdk.tui.executeCommand({ command: "session_new" }))
const showToast = yield* capture(() => sdk.tui.showToast({ title: "SDK", message: "hello", variant: "info" }))
const selectSession = yield* capture(() => sdk.tui.selectSession({ sessionID }))
const missingSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "ses_missing" }))
const invalidSession = yield* capture(() => sdk.tui.selectSession({ sessionID: "invalid_session_id" }))
return {
statuses: statuses({
session,
appendPrompt,
openHelp,
openSessions,
openThemes,
openModels,
submitPrompt,
clearPrompt,
executeCommand,
showToast,
selectSession,
missingSession,
invalidSession,
}),
data: {
appendPrompt: appendPrompt.data,
openHelp: openHelp.data,
openSessions: openSessions.data,
openThemes: openThemes.data,
openModels: openModels.data,
submitPrompt: submitPrompt.data,
clearPrompt: clearPrompt.data,
executeCommand: executeCommand.data,
showToast: showToast.data,
selectSession: selectSession.data,
},
}
}),
),
)
serverPathParity("matches generated SDK project git initialization", (serverPath) =>
withProject(serverPath, {}, ({ sdk, directory }) =>
Effect.gen(function* () {
const before = yield* capture(() => sdk.project.current())
const init = yield* capture(() => sdk.project.initGit())
const after = yield* capture(() => sdk.project.current())
return {
statuses: statuses({ before, init, after }),
before: {
vcs: record(before.data).vcs ?? null,
worktree: record(before.data).worktree,
},
init: {
vcs: record(init.data).vcs,
worktreeSelected: record(init.data).worktree === directory,
},
after: {
vcs: record(after.data).vcs,
worktreeSelected: record(after.data).worktree === directory,
},
}
}),
),
)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,148 @@
import { afterEach, describe, expect, mock } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { Session } from "@/session/session"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const context = Context.empty() as Context.Context<unknown>
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
afterEach(async () => {
mock.restore()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
})
describe("sync HttpApi", () => {
it.instance(
"serves sync routes",
() =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const tmp = yield* TestInstance
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
const session = yield* Session.use.create({ title: "sync" })
const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers })
expect(started.status).toBe(200)
expect(yield* started.json).toBe(true)
const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, {
method: "POST",
headers,
body: JSON.stringify({}),
})
expect(history.status).toBe(200)
const rows = (yield* history.json) as Array<{
id: string
aggregate_id: string
seq: number
type: string
data: Record<string, unknown>
}>
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, {
method: "POST",
headers,
body: JSON.stringify({
directory: tmp.directory,
events: rows
.filter((row) => row.aggregate_id === session.id)
.map((row) => ({
id: row.id,
aggregateID: row.aggregate_id,
seq: row.seq,
type: row.type,
data: row.data,
})),
}),
})
expect(replayed.status).toBe(200)
expect(yield* replayed.json).toEqual({ sessionID: session.id })
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"validates seq values",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
const cases = [
{
path: SyncPaths.history,
body: { aggregate: -1 },
},
{
path: SyncPaths.history,
body: { aggregate: 1.5 },
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
},
},
{
path: SyncPaths.replay,
body: {
directory: tmp.directory,
events: [{ id: "event", aggregateID: "session", seq: 0, type: "session.created", data: {} }],
},
},
]
for (const item of cases) {
const response = yield* requestInDirectory(item.path, tmp.directory, {
method: "POST",
headers,
body: JSON.stringify(item.body),
})
expect(response.status).toBe(400)
}
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance.skip(
"returns structured validation errors",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const response = yield* Effect.promise(() =>
HttpApiApp.webHandler().handler(
new Request(`http://localhost${SyncPaths.history}`, {
method: "POST",
headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" },
body: JSON.stringify({ aggregate: -1 }),
}),
context,
),
)
expect(response.status).toBe(400)
expect(response.headers.get("content-type") ?? "").toContain("application/json")
const body = (yield* Effect.promise(() => response.json())) as Record<string, unknown>
expect(body.success).toBe(false)
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})

View File

@@ -0,0 +1,453 @@
import { createHash } from "node:crypto"
import { describe, expect } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ConfigProvider, Effect, Layer } from "effect"
import {
HttpClient,
HttpClientRequest,
HttpClientResponse,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import { ServerAuth } from "../../src/server/auth"
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
envUsername: process.env.OPENCODE_SERVER_USERNAME,
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
}),
)
}),
)
const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, RuntimeFlags.layer()))
function restoreEnv(key: string, value: string | undefined) {
if (value === undefined) {
delete process.env[key]
return
}
process.env[key] = value
}
function app(input?: { password?: string; username?: string }) {
const handler = HttpRouter.toWebHandler(
HttpApiApp.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
request(input: string | URL | Request, init?: RequestInit) {
return Effect.promise(() =>
Promise.resolve(
handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
HttpApiApp.context,
),
),
)
},
}
}
function uiApp(input?: {
password?: string
username?: string
client?: Layer.Layer<HttpClient.HttpClient>
disableEmbeddedWebUi?: boolean
}) {
const handler = HttpRouter.toWebHandler(
HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const client = yield* HttpClient.HttpClient
const flags = yield* RuntimeFlags.Service
yield* router.add("*", "/*", (request) =>
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
)
}),
).pipe(
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
Layer.provide([
FSUtil.defaultLayer,
input?.client ?? httpClient(new Response("ui")),
RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }),
HttpServer.layerServices,
ConfigProvider.layer(
ConfigProvider.fromUnknown({
OPENCODE_SERVER_PASSWORD: input?.password,
OPENCODE_SERVER_USERNAME: input?.username,
}),
),
]),
),
{ disableLogger: true },
).handler
return {
request(input: string | URL | Request, init?: RequestInit) {
return Effect.promise(() =>
Promise.resolve(
handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
HttpApiApp.context,
),
),
)
},
}
}
function routeOrderingApp() {
let proxiedUrl: string | undefined
const handler = HttpRouter.toWebHandler(
HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const client = yield* HttpClient.HttpClient
const flags = yield* RuntimeFlags.Service
yield* router.add("GET", "/session/:sessionID", () =>
Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })),
)
yield* router.add("*", "/*", (request) =>
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
)
}),
).pipe(
Layer.provide([
FSUtil.defaultLayer,
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
httpClient(new Response("ui"), (request) => {
proxiedUrl = request.url
}),
HttpServer.layerServices,
]),
),
{ disableLogger: true },
).handler
return {
proxiedUrl: () => proxiedUrl,
request(input: string | URL | Request, init?: RequestInit) {
return Effect.promise(() =>
Promise.resolve(
handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
HttpApiApp.context,
),
),
)
},
}
}
function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) {
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => {
onRequest?.(request)
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
}),
)
}
function responseText(response: Response) {
return Effect.promise(() => response.text())
}
describe("HttpApi UI fallback", () => {
it.live("serves the web UI through the HTTP API app", () =>
Effect.gen(function* () {
let proxiedUrl: string | undefined
const response = yield* uiApp({
disableEmbeddedWebUi: true,
client: httpClient(
new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }),
(request) => {
proxiedUrl = request.url
},
),
}).request("/")
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/html")
expect(yield* responseText(response)).toBe("<html>opencode</html>")
expect(proxiedUrl).toBe("https://app.opencode.ai/")
}),
)
it.live("strips upstream transfer encoding headers from proxied assets", () =>
Effect.gen(function* () {
let proxiedUrl: string | undefined
const response = yield* Effect.gen(function* () {
const fs = yield* FSUtil.Service
const client = yield* HttpClient.HttpClient
const flags = yield* RuntimeFlags.Service
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
fs,
client,
disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
})
}).pipe(
Effect.provide(
Layer.mergeAll(
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => {
proxiedUrl = request.url
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response("console.log('ok')", {
headers: {
"content-encoding": "br",
"content-length": "999",
"content-type": "text/javascript",
},
}),
),
)
}),
),
),
),
Effect.map(HttpServerResponse.toWeb),
)
expect(response.status).toBe(200)
expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js")
expect(response.headers.get("content-encoding")).toBeNull()
expect(response.headers.get("content-length")).not.toBe("999")
expect(response.headers.get("content-type")).toContain("text/javascript")
expect(yield* responseText(response)).toBe("console.log('ok')")
}),
)
// Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was
// forwarded through the proxy while the proxy itself re-frames the body,
// causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`.
it.live("strips upstream transfer-encoding header from proxied assets", () =>
Effect.gen(function* () {
const response = yield* Effect.gen(function* () {
const fs = yield* FSUtil.Service
const client = yield* HttpClient.HttpClient
const flags = yield* RuntimeFlags.Service
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), {
fs,
client,
disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
})
}).pipe(
Effect.provide(
Layer.mergeAll(
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response("<html>opencode</html>", {
headers: {
"transfer-encoding": "chunked",
"content-type": "text/html",
},
}),
),
),
),
),
),
),
Effect.map(HttpServerResponse.toWeb),
)
expect(response.status).toBe(200)
expect(response.headers.get("transfer-encoding")).toBeNull()
expect(yield* responseText(response)).toBe("<html>opencode</html>")
}),
)
it.live("serves embedded UI assets when Bun can read them but access reports missing", () =>
Effect.gen(function* () {
let readPath: string | undefined
const fs = yield* FSUtil.Service
const response = yield* serveEmbeddedUIEffect(
"/assets/app.js",
{
...fs,
existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
readFile: (path) => {
readPath = path
return path === "/$bunfs/root/assets/app.js"
? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
: Effect.die(`unexpected embedded UI path: ${path}`)
},
},
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
).pipe(Effect.map(HttpServerResponse.toWeb))
expect(response.status).toBe(200)
expect(readPath).toBe("/$bunfs/root/assets/app.js")
expect(response.headers.get("content-type")).toContain("text/javascript")
expect(yield* responseText(response)).toBe("console.log('embedded')")
}),
)
it.live("allows embedded UI terminal wasm and theme preload CSP", () =>
Effect.gen(function* () {
const script = 'document.documentElement.dataset.theme = "dark"'
const fs = yield* FSUtil.Service
const response = yield* serveEmbeddedUIEffect(
"/",
{
...fs,
readFile: (path) => {
return path === "/$bunfs/root/index.html"
? Effect.succeed(
new TextEncoder().encode(
`<html><head><script id="oc-theme-preload-script">${script}</script></head></html>`,
),
)
: Effect.die(`unexpected embedded UI path: ${path}`)
},
},
{ "index.html": "/$bunfs/root/index.html" },
).pipe(Effect.map(HttpServerResponse.toWeb))
const csp = response.headers.get("content-security-policy") ?? ""
expect(csp).toContain("script-src 'self' 'wasm-unsafe-eval'")
expect(csp).toContain(`'sha256-${createHash("sha256").update(script).digest("base64")}'`)
expect(csp).toContain("connect-src * data:")
}),
)
it.live("keeps matched API routes ahead of the UI fallback", () =>
Effect.gen(function* () {
const server = routeOrderingApp()
const response = yield* server.request("/session/ses_nope")
expect(response.status).toBe(404)
expect(server.proxiedUrl()).toBeUndefined()
}),
)
it.live("requires server password for the web UI", () =>
Effect.gen(function* () {
const response = yield* uiApp({
password: "secret",
username: "opencode",
disableEmbeddedWebUi: true,
}).request("/")
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
}),
)
it.live("accepts auth token for the web UI", () =>
Effect.gen(function* () {
const response = yield* uiApp({
password: "secret",
username: "opencode",
disableEmbeddedWebUi: true,
client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
}).request(`/?auth_token=${btoa("opencode:secret")}`)
expect(response.status).toBe(200)
expect(yield* responseText(response)).toBe("<html>opencode</html>")
}),
)
it.live("accepts basic auth for the web UI", () =>
Effect.gen(function* () {
const response = yield* uiApp({
password: "secret",
username: "opencode",
disableEmbeddedWebUi: true,
}).request("/", {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
expect(response.status).toBe(200)
}),
)
it.live("accepts basic auth passwords containing colons for the web UI", () =>
Effect.gen(function* () {
const response = yield* uiApp({
password: "sec:ret",
username: "opencode",
disableEmbeddedWebUi: true,
}).request("/", {
headers: { authorization: `Basic ${btoa("opencode:sec:ret")}` },
})
expect(response.status).toBe(200)
}),
)
// Regression for #25698 (Ope): the browser fetches the PWA manifest and
// its icons via flows that don't carry app-managed credentials (the
// `<link rel="manifest">` request is not under page-auth control), so the
// server returning 401 breaks PWA install. These specific public assets
// should bypass auth.
it.live("serves the PWA manifest without auth even when a server password is set", () =>
Effect.gen(function* () {
for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
const response = yield* uiApp({
password: "secret",
username: "opencode",
disableEmbeddedWebUi: true,
client: httpClient(new Response("ok")),
}).request(path)
expect(response.status).not.toBe(401)
}
}),
)
it.live("allows web UI preflight without auth", () =>
Effect.gen(function* () {
const response = yield* app({ password: "secret", username: "opencode" }).request("/", {
method: "OPTIONS",
headers: {
origin: "http://localhost:3000",
"access-control-request-method": "GET",
},
})
expect(response.status).toBe(204)
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
}),
)
})

View File

@@ -0,0 +1,82 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Context, Schema } from "effect"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
const context = Context.empty() as Context.Context<unknown>
function request(route: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return HttpApiApp.webHandler().handler(
new Request(`http://localhost${route}`, {
...init,
headers,
}),
context,
)
}
const Event = Schema.Struct({
id: Schema.String,
type: Schema.String,
location: Schema.Struct({
directory: Schema.String,
project: Schema.Struct({ id: Schema.String, directory: Schema.String }),
}),
data: Schema.Unknown,
})
async function readEvent(reader: ReadableStreamDefaultReader<Uint8Array>) {
const value = await reader.read()
if (value.done) throw new Error("event stream closed")
return Schema.decodeUnknownSync(Event)(JSON.parse(new TextDecoder().decode(value.value).replace(/^data: /, "")))
}
async function readEventType(reader: ReadableStreamDefaultReader<Uint8Array>, type: string) {
for (let index = 0; index < 20; index++) {
const event = await readEvent(reader)
if (event.type === type) return event
}
throw new Error(`timed out waiting for ${type}`)
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
describe("v2 location HttpApi", () => {
test("returns command and skill snapshots with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
for (const route of ["/api/command", "/api/skill"]) {
const response = await request(route, tmp.path)
expect(response.status).toBe(200)
const body = (await response.json()) as {
location: { directory: string; project: { id: string } }
data: unknown
}
expect(body.data).toBeArray()
expect(body.location.directory).toBe(tmp.path)
expect(body.location.project.id).toBeTruthy()
}
})
test("streams native EventV2 payloads with resolved locations", async () => {
await using tmp = await tmpdir({ git: true })
const response = await request("/api/event", tmp.path)
const reader = response.body!.getReader()
expect((await readEvent(reader)).type).toBe("server.connected")
const created = await request("/session", tmp.path, { method: "POST" })
expect(created.status).toBe(200)
expect(await readEventType(reader, "session.created")).toMatchObject({
type: "session.created",
location: { directory: tmp.path, project: { directory: tmp.path } },
data: { sessionID: expect.any(String) },
})
await reader.cancel()
})
})

View File

@@ -0,0 +1,555 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import Http from "node:http"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Database } from "@opencode-ai/core/database/database"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRouteContext,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
import { resetDatabase } from "../fixture/db"
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
await resetDatabase()
}),
)
}),
)
const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true })
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
Database.defaultLayer,
Project.defaultLayer,
workspaceLayer,
Socket.layerWebSocketConstructorGlobal,
).pipe(Layer.provide(Ripgrep.defaultLayer)),
)
type ProxiedRequest = {
url: string
method: string
headers: Record<string, string>
body: string
}
type TestHandler<E, R> = (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe(
Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
)
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 localAdapter = (directory: string): WorkspaceAdapter => ({
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 remoteAdapter = (directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter => ({
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; adapter: WorkspaceAdapter }) =>
Effect.acquireRelease(
Effect.gen(function* () {
registerAdapter(input.projectID, input.type, input.adapter)
const workspace = yield* Workspace.Service
return yield* workspace.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
})
}),
(info) => Workspace.use.remove(info.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,
adapter: remoteAdapter(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,
adapter: localAdapter(input.directory),
})
const insertRemoteWorkspaceWithoutSync = (input: {
dir: string
projectID: Project.Info["id"]
type: string
url: string
}) =>
Effect.gen(function* () {
const id = WorkspaceV2.ID.ascending()
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
const { db } = yield* Database.Service
yield* db
.insert(WorkspaceTable)
.values({ id, type: input.type, project_id: input.projectID })
.run()
.pipe(Effect.orDie)
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,
body: yield* request.text,
})
}),
)
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 ProbeResult = Schema.Struct({
directory: Schema.String,
workspaceID: Schema.optional(Schema.String),
})
const ProbeApi = HttpApi.make("workspace-routing-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
success: ProbeResult,
}),
)
.middleware(WorkspaceRoutingMiddleware),
)
const routeContextResponse = Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return { directory: route.directory, workspaceID: route.workspaceID }
})
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("get", () => routeContextResponse)
.handle("patch", () => Effect.succeed(false))
.handle("session", () => routeContextResponse)
.handle("workspace", () => routeContextResponse),
)
const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(workspaceRoutingTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
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 adapter 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* serveProbe
const body = '{"title":"Remote workspace request"}'
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
HttpClientRequest.setHeaders({
"x-opencode-directory": "/secret/path",
"x-opencode-workspace": "internal",
}),
HttpClientRequest.bodyStream(
Stream.make(new TextEncoder().encode('{"title":"Remote '), new TextEncoder().encode('workspace request"}')),
{ contentType: "application/json" },
),
HttpClient.execute,
Effect.timeout("2 seconds"),
)
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?.body).toBe(body)
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("waits for sync fence headers from remote workspace HTTP responses", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceID = WorkspaceV2.ID.ascending()
const type = "remote-http-fence-target"
const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record<string, number> } | undefined>(
undefined,
)
const remoteUrl = yield* startRemoteWorkspaceHttpServer(() =>
HttpServerResponse.json(
{ proxied: true },
{ status: 202, headers: { [FenceHeader]: JSON.stringify({ aggregate: 3 }) } },
),
)
registerAdapter(project.project.id, type, remoteAdapter(path.join(dir, `.${type}`), `${remoteUrl}/base`))
const workspace = Workspace.Service.of({
create: () => Effect.die("unused"),
sessionWarp: () => Effect.die("unused"),
list: () => Effect.die("unused"),
syncList: () => Effect.die("unused"),
get: (id) =>
Effect.succeed(
id === workspaceID
? {
id: workspaceID,
type,
branch: null,
name: "remote-http-fence-target",
directory: null,
extra: null,
projectID: project.project.id,
timeUsed: Date.now(),
}
: undefined,
),
remove: () => Effect.die("unused"),
status: () => Effect.die("unused"),
isSyncing: () => Effect.succeed(true),
waitForSync: (id, state) => Ref.set(waited, { workspaceID: id, state }),
startWorkspaceSyncing: () => Effect.die("unused"),
})
yield* HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(workspaceRoutingTestLayer),
Layer.provide(Layer.succeed(Workspace.Service, workspace)),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspaceID}`).pipe(HttpClient.execute)
expect(response.status).toBe(202)
expect(yield* response.json).toEqual({ proxied: true })
expect(yield* Ref.get(waited)).toEqual({ workspaceID, state: { aggregate: 3 } })
}),
)
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* serveProbe
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* serveProbe
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 = WorkspaceV2.ID.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* serveProbe
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* serveProbe
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("keeps workspace control 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: "workspace-control-plane-target",
directory: workspaceDir,
})
// Workspace CRUD/status routes manage the control plane itself. Selecting
// a workspace should preserve the selected id for handlers, but must not
// swap the route context to the workspace target directory.
yield* serveProbe
const response = yield* HttpClient.get(`${WorkspacePaths.list}?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* serveProbe
// 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, workspaceID: null })
expect(headerResponse.status).toBe(200)
expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null })
}),
)
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* serveProbe
// /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,
})
}),
)
})

View File

@@ -0,0 +1,513 @@
import { afterEach, describe, expect, mock } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect, Layer, Stream } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { Session } from "@/session/session"
import { Database } from "@opencode-ai/core/database/database"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const workspaceLayer = Workspace.defaultLayer.pipe(
Layer.provide(InstanceStore.defaultLayer),
Layer.provide(InstanceBootstrap.defaultLayer),
)
const it = testEffect(
Layer.mergeAll(
Project.defaultLayer,
Session.defaultLayer,
workspaceLayer,
InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
Database.defaultLayer,
httpApiLayer,
).pipe(Layer.provide(Ripgrep.defaultLayer)),
)
function request(path: string, directory: string, init: RequestInit = {}) {
return requestInDirectory(path, directory, init)
}
function requestDefault(path: string, directory: string, init: RequestInit = {}) {
return requestInDirectory(path, directory, init)
}
function requestServer(path: string, directory: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("x-opencode-directory", directory)
return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers })))
}
function localAdapter(directory: string): WorkspaceAdapter {
return {
name: "Local Test",
description: "Create a local test workspace",
configure(info) {
return {
...info,
name: "local-test",
directory,
}
},
async create() {
await mkdir(directory, { recursive: true })
},
async remove() {},
target() {
return {
type: "local" as const,
directory,
}
},
}
}
function listedAdapter(directory: string, type: string): WorkspaceAdapter {
return {
name: "Listed Test",
description: "List a local test workspace",
configure(info) {
return { ...info, name: "unused", directory }
},
async create() {},
async remove() {},
list(context) {
return [
{
type,
name: "listed-test",
branch: "listed/main",
directory,
extra: { listed: true },
projectID: context?.instance?.project.id ?? missingAdapterContext(),
},
]
},
target() {
return {
type: "local" as const,
directory,
}
},
}
}
function missingAdapterContext(): never {
throw new Error("missing workspace adapter context")
}
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
return {
name: "Remote Test",
description: "Create a remote test workspace",
configure(info) {
return {
...info,
name: "remote-test",
directory,
}
},
async create() {
await mkdir(directory, { recursive: true })
},
async remove() {},
target() {
return {
type: "remote" as const,
url,
headers,
}
},
}
}
type ProxiedRequest = {
url: string
method: string
headers: Record<string, string>
body: string
}
function listenRemoteHttp(handler: (request: ProxiedRequest) => Response | Promise<Response>) {
return Bun.serve({
port: 0,
async fetch(request) {
return handler({
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body: await request.text(),
})
},
})
}
function eventStreamResponse() {
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode('data: {"payload":{"type":"server.connected","properties":{}}}\n\n'),
)
},
}),
{
status: 200,
headers: {
"content-type": "text/event-stream",
},
},
)
}
afterEach(async () => {
mock.restore()
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
})
describe("workspace HttpApi", () => {
it.live("serves read endpoints", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const [adapters, workspaces, status] = yield* Effect.all([
request(WorkspacePaths.adapters, dir),
request(WorkspacePaths.list, dir),
request(WorkspacePaths.status, dir),
])
expect(adapters.status).toBe(200)
expect(yield* adapters.json).toContainEqual({
type: "worktree",
name: "Worktree",
description: "Create a git worktree",
})
expect(workspaces.status).toBe(200)
expect(yield* workspaces.json).toEqual([])
expect(status.status).toBe(200)
expect(yield* status.json).toEqual([])
}),
)
it.live("serves mutation endpoints", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null }),
})
expect(created.status).toBe(200)
const workspace = (yield* created.json) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
const warped = yield* request(WorkspacePaths.warp, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
})
expect(warped.status).toBe(204)
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
expect(removed.status).toBe(200)
expect(yield* removed.json).toMatchObject({ id: workspace.id })
const listed = yield* request(WorkspacePaths.list, dir)
expect(listed.status).toBe(200)
expect(yield* listed.json).toEqual([])
}),
)
it.live("serves list sync endpoint", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const type = `listed-${Math.random().toString(36).slice(2)}`
registerAdapter(project.project.id, type, listedAdapter(path.join(dir, ".listed"), type))
const response = yield* request(WorkspacePaths.syncList, dir, { method: "POST" })
expect(response.status).toBe(204)
const listed = yield* request(WorkspacePaths.list, dir)
expect(yield* listed.json).toMatchObject([
{
type,
name: "listed-test",
branch: "listed/main",
directory: path.join(dir, ".listed"),
extra: { listed: true },
},
])
}),
)
it.live("returns a declared not found error when warping into a missing workspace", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp")
const response = yield* request(WorkspacePaths.warp, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: workspaceID, sessionID: session.id }),
})
expect(response.status).toBe(404)
expect(yield* response.json).toEqual({
name: "NotFoundError",
data: { message: `Workspace not found: ${workspaceID}` },
})
}),
)
it.live("creates workspace with the TUI payload shape", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
registerAdapter(project.project.id, "local-test", localAdapter(path.join(dir, ".workspace")))
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null }),
})
expect(created.status).toBe(200)
expect((yield* created.json) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
})
}),
)
it.live("creates a real git worktree workspace via the builtin adapter", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const created = yield* requestServer(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null }),
})
const body = yield* Effect.promise(() => created.text())
expect({ status: created.status, body }).toMatchObject({ status: 200 })
const workspace = JSON.parse(body) as Workspace.Info
expect(workspace).toMatchObject({ type: "worktree" })
}),
)
it.live("routes local workspace requests through the workspace target directory", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const workspaceDir = path.join(dir, ".workspace-local")
const project = yield* Project.use.fromDirectory(dir)
registerAdapter(project.project.id, "local-target", localAdapter(workspaceDir))
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-target", branch: null }),
})
const workspace = (yield* created.json) as Workspace.Info
const url = new URL(`http://localhost${InstancePaths.path}`)
url.searchParams.set("workspace", workspace.id)
const response = yield* request(url.toString(), dir)
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({ directory: workspaceDir })
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}),
)
it.live("proxies remote workspace HTTP requests with sanitized forwarding", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const proxied: ProxiedRequest[] = []
const remote = listenRemoteHttp((request) => {
proxied.push(request)
const url = new URL(request.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
return new Response(
JSON.stringify({
proxied: true,
path: url.pathname,
keep: url.searchParams.get("keep"),
workspace: url.searchParams.get("workspace"),
}),
{
status: 201,
statusText: "Created",
headers: {
"content-length": "999",
"content-type": "application/json",
"x-remote": "yes",
},
},
)
})
const project = yield* Project.use.fromDirectory(dir)
registerAdapter(
project.project.id,
"remote-target",
remoteAdapter(path.join(dir, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
"x-target-auth": "secret",
}),
)
const created = yield* requestDefault(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-target", branch: null }),
})
const workspace = (yield* created.json) as Workspace.Info
const url = new URL("http://localhost/config")
url.searchParams.set("workspace", workspace.id)
url.searchParams.set("keep", "yes")
try {
const response = yield* requestDefault(url.toString(), dir, {
method: "PATCH",
headers: {
"accept-encoding": "br",
"content-type": "application/json",
"x-opencode-workspace": "internal",
},
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
})
const responseBody = yield* response.text
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
expect(response.headers["content-length"]).toBeUndefined()
expect(response.headers["x-remote"]).toBe("yes")
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
expect(forwarded).toEqual([
{
url: `http://127.0.0.1:${remote.port}/base/config?keep=yes`,
method: "PATCH",
headers: expect.objectContaining({
"content-type": "application/json",
"x-target-auth": "secret",
}),
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
},
])
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
const eventURL = new URL(`http://localhost${EventPaths.event}`)
eventURL.searchParams.set("workspace", workspace.id)
const eventResponse = yield* request(eventURL.toString(), dir)
expect(eventResponse.status).toBe(200)
expect(eventResponse.headers["content-type"]).toContain("text/event-stream")
const event = Array.from(yield* eventResponse.stream.pipe(Stream.take(1), Stream.runCollect))[0]
expect(new TextDecoder().decode(event)).toContain("server.connected")
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
} finally {
void remote.stop(true)
yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}
}),
)
it.live("proxies remote workspace requests selected from session ownership", () =>
Effect.gen(function* () {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const proxied: ProxiedRequest[] = []
const remote = listenRemoteHttp((request) => {
proxied.push(request)
const url = new URL(request.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
return Response.json({ proxied: true, path: new URL(request.url).pathname })
})
const project = yield* Project.use.fromDirectory(dir)
registerAdapter(
project.project.id,
"remote-session-target",
remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
)
const created = yield* requestDefault(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-session-target", branch: null }),
})
const workspace = (yield* created.json) as Workspace.Info
const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" })
const session = (yield* sessionResponse.json) as Session.Info
const warped = yield* requestDefault(WorkspacePaths.warp, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
})
expect(warped.status).toBe(204)
try {
const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
})
const responseBody = yield* response.text
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
expect.objectContaining({
url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/message`,
method: "POST",
}),
])
const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" })
expect(aborted.status).toBe(200)
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([
expect.objectContaining({
url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`,
method: "POST",
body: "",
}),
])
} finally {
void remote.stop(true)
yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}
}),
)
})

View File

@@ -0,0 +1,83 @@
// Regression: a stored step-finish part with a negative token count made the
// messages endpoint 400. Some providers reported `outputTokens` excluding
// reasoning while also reporting `reasoningTokens` separately, so the
// `outputTokens - reasoningTokens` math in Session.getUsage underflowed to
// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
// strict `NonNegativeInt` schema then made every load of the message list
// fail to encode, killing Desktop boot for every user with such a row.
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { eq } from "drizzle-orm"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID } from "../../src/session/schema"
import { Database } from "@opencode-ai/core/database/database"
import { PartTable } from "@opencode-ai/core/session/sql"
import { resetDatabase } from "../fixture/db"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
function seedNegativeTokenSession() {
return Effect.gen(function* () {
const session = yield* Session.Service
const info = yield* session.create({})
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID: info.id,
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") },
time: { created: Date.now() },
})
const partID = PartID.ascending()
yield* session.updatePart({
id: partID,
sessionID: info.id,
messageID: message.id,
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
// Bypass the schema with a direct SQL update to install the
// negative `output` value we want to test loading.
const { db } = yield* Database.Service
yield* db
.update(PartTable)
.set({
data: {
type: "step-finish",
reason: "stop",
cost: 0,
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
} as never,
})
.where(eq(PartTable.id, partID))
.run()
.pipe(Effect.orDie)
return info.id
})
}
describe("messages endpoint tolerates legacy negative token counts", () => {
it.instance(
"returns 200 even when a step-finish part has tokens.output < 0",
Effect.gen(function* () {
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
const test = yield* TestInstance
const sessionID = yield* seedNegativeTokenSession()
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
const res = yield* requestInDirectory(url, test.directory)
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})

View File

@@ -0,0 +1,109 @@
import { afterEach, describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { Effect, Layer } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Database } from "@opencode-ai/core/database/database"
import { Snapshot } from "@/snapshot"
import { InstanceBootstrap } from "@/project/bootstrap-service"
import { InstanceStore } from "@/project/instance-store"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
const it = testEffect(
Layer.mergeAll(FSUtil.defaultLayer, Database.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer),
)
function request(directory: string, url: string, init: RequestInit = {}) {
return requestInDirectory(url, directory, init)
}
function json<T>(response: HttpClientResponse.HttpClientResponse) {
return response.json.pipe(Effect.map((value) => value as T))
}
describe("project directories and copies endpoints", () => {
type ProjectDirectory = { directory: string; type: "main" | "root" | "git_worktree" }
it.instance(
"lists directories and manages git worktree copies",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const current = yield* request(test.directory, "/project/current")
const projectID = (yield* json<{ id: string }>(current)).id
const base = `/project/${projectID}`
const copies = `/experimental/project/${projectID}/copy`
const createdParent = path.join(test.directory, "..", path.basename(test.directory) + "-http-copy")
const createdDirectory = path.join(createdParent, "copy")
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(createdParent, { recursive: true, force: true })).pipe(Effect.ignore),
)
const initial = yield* request(test.directory, `${base}/directories`)
expect(initial.status).toBe(200)
expect(yield* json<ProjectDirectory[]>(initial)).toEqual([{ directory: test.directory, type: "main" }])
const create = yield* request(test.directory, copies, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ strategy: "git_worktree", directory: createdParent, name: "copy" }),
})
expect(create.status).toBe(200)
const created = yield* json<{ directory: string }>(create)
expect(created.directory).toBe(createdDirectory)
const listed = yield* request(test.directory, `${base}/directories`)
expect(yield* json<ProjectDirectory[]>(listed)).toContainEqual({
directory: created.directory,
type: "git_worktree",
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
const remove = yield* request(test.directory, copies, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ directory: created.directory, force: false }),
})
expect(remove.status).toBe(400)
expect(yield* json<{ data: { forceRequired?: boolean } }>(remove)).toMatchObject({
data: { forceRequired: true },
})
const forced = yield* request(test.directory, copies, {
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ directory: created.directory, force: true }),
})
expect(forced.status).toBe(204)
const externalDirectory = path.join(test.directory, "..", path.basename(test.directory) + "-http-refresh")
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(externalDirectory, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${externalDirectory} HEAD`.cwd(test.directory).quiet())
const refresh = yield* request(test.directory, `${copies}/refresh`, {
method: "POST",
})
expect(refresh.status).toBe(204)
const refreshed = yield* request(test.directory, `${base}/directories`)
expect(yield* json<ProjectDirectory[]>(refreshed)).toEqual([
{ directory: externalDirectory, type: "git_worktree" },
{ directory: test.directory, type: "main" },
])
}),
{ git: true },
)
})

View File

@@ -0,0 +1,114 @@
import { afterEach, describe, expect } from "bun:test"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Effect, Layer } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
import path from "path"
import { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
import { InstanceStore } from "../../src/project/instance-store"
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
import { Snapshot } from "../../src/snapshot"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer))
function request(directory: string, url: string, init: RequestInit = {}) {
return requestInDirectory(url, directory, init)
}
function json<T>(response: HttpClientResponse.HttpClientResponse) {
return response.json.pipe(Effect.map((value) => value as T))
}
function collectGlobalEvents() {
return Effect.acquireRelease(
Effect.sync(() => {
const seen: GlobalEvent[] = []
const on = (event: GlobalEvent) => {
seen.push(event)
}
GlobalBus.on("event", on)
return { seen, on }
}),
({ on }) => Effect.sync(() => GlobalBus.off("event", on)),
)
}
const disposedEvents = (seen: GlobalEvent[], dir: string) =>
seen.filter((evt) => evt.directory === dir && evt.payload.type === "server.instance.disposed").length
describe("project.initGit endpoint", () => {
it.instance("initializes git and reloads immediately", () =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const fs = yield* FSUtil.Service
const events = yield* collectGlobalEvents()
const init = yield* request(tmp.directory, "/project/git/init", {
method: "POST",
})
const body = yield* json(init)
expect(init.status).toBe(200)
expect(body).toMatchObject({
id: "global",
vcs: "git",
worktree: tmp.directory,
})
// Reload behavior: bus emits exactly one server.instance.disposed for the directory.
expect(disposedEvents(events.seen, tmp.directory)).toBe(1)
expect(yield* fs.exists(path.join(tmp.directory, ".git", "opencode"))).toBe(false)
const current = yield* request(tmp.directory, "/project/current")
expect(current.status).toBe(200)
expect(yield* json(current)).toMatchObject({
id: "global",
vcs: "git",
worktree: tmp.directory,
})
const ctx = yield* InstanceStore.use.reload({ directory: tmp.directory })
const tracked = yield* Snapshot.Service.use((snapshot) => snapshot.track()).pipe(
Effect.provideService(InstanceRef, ctx),
)
expect(tracked).toBeTruthy()
}),
)
it.instance(
"does not reload when the project is already git",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const events = yield* collectGlobalEvents()
const init = yield* request(tmp.directory, "/project/git/init", {
method: "POST",
})
expect(init.status).toBe(200)
expect(yield* json(init)).toMatchObject({
vcs: "git",
worktree: tmp.directory,
})
expect(disposedEvents(events.seen, tmp.directory)).toBe(0)
const current = yield* request(tmp.directory, "/project/current")
expect(current.status).toBe(200)
expect(yield* json(current)).toMatchObject({
vcs: "git",
worktree: tmp.directory,
})
}),
{ git: true },
)
})

View File

@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test"
import { ProxyUtil } from "../../src/server/proxy-util"
describe("ProxyUtil", () => {
describe("websocketTargetURL", () => {
test("converts http to ws", () => {
expect(ProxyUtil.websocketTargetURL("http://example.com/path")).toBe("ws://example.com/path")
})
test("converts https to wss", () => {
expect(ProxyUtil.websocketTargetURL("https://example.com/path")).toBe("wss://example.com/path")
})
test("preserves query params", () => {
expect(ProxyUtil.websocketTargetURL("http://example.com/path?foo=bar")).toBe("ws://example.com/path?foo=bar")
})
test("accepts URL objects", () => {
expect(ProxyUtil.websocketTargetURL(new URL("http://localhost:3000/ws"))).toBe("ws://localhost:3000/ws")
})
})
describe("websocketProtocols", () => {
test("returns empty array when no header", () => {
const req = new Request("http://localhost")
expect(ProxyUtil.websocketProtocols(req)).toEqual([])
})
test("parses single protocol", () => {
const req = new Request("http://localhost", {
headers: { "sec-websocket-protocol": "graphql-ws" },
})
expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws"])
})
test("parses multiple protocols", () => {
const req = new Request("http://localhost", {
headers: { "sec-websocket-protocol": "graphql-ws, graphql-transport-ws" },
})
expect(ProxyUtil.websocketProtocols(req)).toEqual(["graphql-ws", "graphql-transport-ws"])
})
test("trims whitespace and filters empty", () => {
const req = new Request("http://localhost", {
headers: { "sec-websocket-protocol": " proto1 , , proto2 " },
})
expect(ProxyUtil.websocketProtocols(req)).toEqual(["proto1", "proto2"])
})
})
describe("headers", () => {
test("strips hop-by-hop headers", () => {
const req = new Request("http://localhost", {
headers: {
connection: "keep-alive",
"keep-alive": "timeout=5",
"transfer-encoding": "chunked",
"content-type": "application/json",
},
})
const result = ProxyUtil.headers(req)
expect(result.get("connection")).toBeNull()
expect(result.get("keep-alive")).toBeNull()
expect(result.get("transfer-encoding")).toBeNull()
expect(result.get("content-type")).toBe("application/json")
})
test("strips opencode-specific headers", () => {
const req = new Request("http://localhost", {
headers: {
"x-opencode-directory": "/home/user/project",
"x-opencode-workspace": "ws_123",
"accept-encoding": "gzip",
"x-custom": "keep",
},
})
const result = ProxyUtil.headers(req)
expect(result.get("x-opencode-directory")).toBeNull()
expect(result.get("x-opencode-workspace")).toBeNull()
expect(result.get("accept-encoding")).toBeNull()
expect(result.get("x-custom")).toBe("keep")
})
test("merges extra headers", () => {
const req = new Request("http://localhost", {
headers: { "content-type": "application/json" },
})
const result = ProxyUtil.headers(req, { "x-auth": "token", "content-type": "text/plain" })
expect(result.get("x-auth")).toBe("token")
expect(result.get("content-type")).toBe("text/plain")
})
test("returns original headers when no extra", () => {
const req = new Request("http://localhost", {
headers: { "content-type": "application/json", "x-foo": "bar" },
})
const result = ProxyUtil.headers(req)
expect(result.get("content-type")).toBe("application/json")
expect(result.get("x-foo")).toBe("bar")
})
test("accepts plain object (HeadersInit) as input", () => {
const result = ProxyUtil.headers(
{ "content-type": "application/json", connection: "keep-alive", "x-custom": "val" },
{ "x-extra": "added" },
)
expect(result.get("connection")).toBeNull()
expect(result.get("content-type")).toBe("application/json")
expect(result.get("x-custom")).toBe("val")
expect(result.get("x-extra")).toBe("added")
})
})
})

View File

@@ -0,0 +1,81 @@
/**
* Regression tests for the SDK error shape — the v2 SDK's `throwOnError: true`
* path used to throw raw values (empty strings or POJOs from JSON-decoded
* error bodies). The TUI catches those and `e.message`/`e.stack` are
* undefined, so users see `[object Object]` or a blank crash.
*
* Both cases must throw a real `Error` instance with a non-empty `.message`
* extracted from the response body, plus `.status` and `.body` attached.
*/
import { afterEach, describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { Server } from "../../src/server/server"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function client(directory: string) {
return createOpencodeClient({
baseUrl: "http://test",
directory,
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
})
}
describe("v2 SDK error shape", () => {
test("404 with NamedError body throws a real Error carrying the server message", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
let caught: unknown
try {
await sdk.session.get({ sessionID: "ses_no_such" }, { throwOnError: true })
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
const err = caught as Error
const cause = err.cause as { body?: any; status?: number }
expect(err.message).toContain("Session not found")
expect(cause.status).toBe(404)
expect(cause.body).toMatchObject({
name: "NotFoundError",
data: { message: expect.stringContaining("Session not found") },
})
})
test("400 schema rejection: SDK extracts the field-level reason from the NamedError body", async () => {
// Canary for the #26631 wire shape. Asserts the contract end-to-end:
// server emits {name:"BadRequest", data:{message, kind}}, SDK's
// wrapClientError extracts .data.message into Error.message. If either
// side regresses (#26457 reverted because both layers were missing),
// this test fails before users see (empty response body).
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
let caught: unknown
try {
await sdk.sync.history.list({ body: { aggregate: -1 } as any }, { throwOnError: true })
} catch (e) {
caught = e
}
expect(caught).toBeInstanceOf(Error)
const err = caught as Error
const cause = err.cause as { body?: any; status?: number }
expect(cause.status).toBe(400)
expect(cause.body).toMatchObject({
name: "BadRequest",
data: { kind: expect.stringMatching(/^(Body|Payload)$/) },
})
expect(typeof cause.body.data.message).toBe("string")
expect(cause.body.data.message.length).toBeGreaterThan(0)
// Whatever the server put in data.message must be what the user sees.
expect(err.message).toBe(cause.body.data.message)
})
})

View File

@@ -0,0 +1,57 @@
// Smoke test: v1 SDK (the plugin contract) can actually reach core endpoints
// against the current server. v1 generation has been frozen since #5216
// (2025-12-07) so types may be stale, but runtime calls should still work
// for endpoints the v1 SDK was generated against.
import { afterEach, describe, expect, test } from "bun:test"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../../src/server/server"
import { tmpdir, disposeAllInstances } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function client(directory: string) {
return createOpencodeClient({
baseUrl: "http://test",
directory,
fetch: ((req: Request) => Server.Default().app.fetch(req)) as unknown as typeof fetch,
})
}
describe("v1 SDK runtime smoke", () => {
test("session.list reaches the server and returns 200", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
const result = await sdk.session.list()
expect(result.error).toBeUndefined()
expect(Array.isArray(result.data)).toBe(true)
})
test("path.get reaches the server and returns 200", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
const result = await sdk.path.get()
expect(result.error).toBeUndefined()
expect(result.data).toBeDefined()
})
test("config.get reaches the server and returns 200", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
const result = await sdk.config.get()
expect(result.error).toBeUndefined()
expect(result.data).toBeDefined()
})
test("session 404: result-tuple path returns the error body", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const sdk = client(tmp.path)
const result = await sdk.session.get({ path: { id: "ses_no_such" } as never })
expect(result.error).toBeDefined()
// wire body for 404 is NamedError-shaped
expect(result.error).toMatchObject({ name: "NotFoundError" })
})
})

View File

@@ -0,0 +1,109 @@
import { afterEach, describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { Session as SessionNs } from "@/session/session"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
afterEach(async () => {
mock.restore()
await disposeAllInstances()
})
describe("session action routes", () => {
it.instance(
"session routes expose metadata on create, update, get, and fork",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const headers = { "Content-Type": "application/json" }
const created = yield* requestInDirectory("/session", test.directory, {
method: "POST",
headers,
body: JSON.stringify({
title: "meta-session",
metadata: { source: "sdk", trace: { id: "abc" } },
}),
})
expect(created.status).toBe(200)
const session = (yield* created.json) as SessionNs.Info
expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } })
const updated = yield* requestInDirectory(`/session/${session.id}`, test.directory, {
method: "PATCH",
headers,
body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }),
})
expect(updated.status).toBe(200)
const next = (yield* updated.json) as SessionNs.Info
expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] })
const fetched = yield* requestInDirectory(`/session/${session.id}`, test.directory)
expect(fetched.status).toBe(200)
expect(((yield* fetched.json) as SessionNs.Info).metadata).toEqual(next.metadata)
const forked = yield* requestInDirectory(`/session/${session.id}/fork`, test.directory, {
method: "POST",
headers,
body: JSON.stringify({}),
})
expect(forked.status).toBe(200)
const fork = (yield* forked.json) as SessionNs.Info
expect(fork.metadata).toEqual(next.metadata)
const reset = yield* requestInDirectory(`/session/${session.id}`, test.directory, {
method: "PATCH",
headers,
body: JSON.stringify({ metadata: {} }),
})
expect(reset.status).toBe(200)
expect(((yield* reset.json) as SessionNs.Info).metadata).toEqual({})
yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore))
yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore))
}),
{ git: true },
)
it.instance(
"abort route returns success",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) =>
SessionNs.use.remove(created.id).pipe(Effect.ignore),
)
const res = yield* requestInDirectory(`/session/${session.id}/abort`, test.directory, { method: "POST" })
expect(res.status).toBe(200)
expect(yield* res.json).toBe(true)
}),
{ git: true },
)
it.instance(
"experimental background route is a no-op without synchronous subagents",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) =>
SessionNs.use.remove(created.id).pipe(Effect.ignore),
)
const res = yield* requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, {
method: "POST",
})
expect(res.status).toBe(200)
expect(yield* res.json).toBe(false)
}),
{ git: true },
)
})

View File

@@ -0,0 +1,96 @@
/**
* Regression test for the same bug class as #26574 (sibling of #26566 and
* #26553). The Desktop app calls GET /session/<id>/diff; before #26574
* the response was Schema-encoded against `Snapshot.FileDiff` with
* `patch: Schema.String` (required), so any session whose stored
* `summary_diffs` had a row without `patch` returned HTTP 400 and the
* session never loaded. Legacy session-level diffs are no longer surfaced,
* but the endpoint remains compatible and must still return successfully.
*
* This test inserts a session row with a missing-patch diff entry and
* asserts that GET /session/<id>/diff returns 200 with empty data.
*/
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { Storage } from "@/storage/storage"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { MessageID } from "@/session/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer))
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
function pathFor(template: string, params: Record<string, string>) {
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template)
}
const withSession = (input?: Parameters<Session.Interface["create"]>[0]) =>
Effect.acquireRelease(Session.use.create(input), (created) => Session.use.remove(created.id).pipe(Effect.ignore))
describe("session diff with missing patch (#26574)", () => {
it.instance(
"GET /session/<id>/diff ignores legacy session-level diff storage",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const session = yield* withSession({ title: "missing-patch" })
// Mimic legacy/imported on-disk shape: a diff entry with no
// `patch` text. Pre-fix the typed response encoder rejects
// this and returns 400.
yield* Storage.Service.use((storage) =>
storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]),
)
const response = yield* requestInDirectory(
pathFor(SessionPaths.diff, { sessionID: session.id }),
test.directory,
)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual([])
}),
{ git: true, config: { formatter: false, lsp: false } },
)
it.instance(
"GET /session/<id>/diff returns requested turn diffs",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const session = yield* withSession({ title: "turn-diff" })
const messageID = MessageID.ascending()
yield* Session.use.updateMessage({
id: messageID,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent: "build",
model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") },
summary: {
diffs: [{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }],
},
} satisfies SessionV1.User)
const response = yield* requestInDirectory(
`${pathFor(SessionPaths.diff, { sessionID: session.id })}?messageID=${messageID}`,
test.directory,
)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual([{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }])
}),
{ git: true, config: { formatter: false, lsp: false } },
)
})

View File

@@ -0,0 +1,312 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { Session as SessionNs } from "@/session/session"
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
import { mkdir } from "fs/promises"
import path from "path"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { eq } from "drizzle-orm"
import { testEffect } from "../lib/effect"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Storage } from "@/storage/storage"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { BackgroundJob } from "@/background/job"
const layer = (experimentalWorkspaces: boolean) =>
Layer.mergeAll(
Database.defaultLayer,
SessionNs.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Storage.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
Layer.provide(BackgroundJob.defaultLayer),
),
)
const it = testEffect(layer(false))
const itWorkspaces = testEffect(layer(true))
const withSession = (input?: Parameters<SessionNs.Interface["create"]>[0]) =>
Effect.acquireRelease(SessionNs.use.create(input), (created) =>
SessionNs.Service.use((session) => session.remove(created.id).pipe(Effect.ignore)),
)
afterEach(async () => {
await disposeAllInstances()
})
describe("session.list", () => {
it.instance(
"does not filter by directory when directory is omitted",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
const root = yield* withSession({ title: "root" })
const parent = yield* withSession({ title: "parent" }).pipe(
provideInstance(path.join(test.directory, "packages")),
)
const current = yield* withSession({ title: "current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode")),
)
const sibling = yield* withSession({ title: "sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
const ids = (yield* SessionNs.use.list()).map((session) => session.id)
expect(ids).toContain(root.id)
expect(ids).toContain(parent.id)
expect(ids).toContain(current.id)
expect(ids).toContain(sibling.id)
}),
{ git: true },
)
it.instance(
"filters by directory when directory is provided",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
const root = yield* withSession({ title: "root" })
const parent = yield* withSession({ title: "parent" }).pipe(
provideInstance(path.join(test.directory, "packages")),
)
const current = yield* withSession({ title: "current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode")),
)
const sibling = yield* withSession({ title: "sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
const ids = (yield* SessionNs.Service.use((session) =>
session.list({ directory: path.join(test.directory, "packages", "opencode") }),
)).map((session) => session.id)
expect(ids).not.toContain(root.id)
expect(ids).not.toContain(parent.id)
expect(ids).toContain(current.id)
expect(ids).not.toContain(sibling.id)
}),
{ git: true },
)
itWorkspaces.instance(
"filters by directory when experimental workspaces are enabled",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "opencode"), { recursive: true }))
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
const current = yield* withSession({ title: "current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode")),
)
const sibling = yield* withSession({ title: "sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
const ids = (yield* SessionNs.Service.use((session) =>
session.list({ directory: path.join(test.directory, "packages", "opencode") }),
)).map((session) => session.id)
expect(ids).toContain(current.id)
expect(ids).not.toContain(sibling.id)
}),
{ git: true },
)
it.instance(
"matches a session regardless of directory separator on Windows",
() =>
Effect.gen(function* () {
if (process.platform !== "win32") return
const test = yield* TestInstance
const dir = path.join(test.directory, "packages", "opencode")
yield* Effect.promise(() => mkdir(dir, { recursive: true }))
const created = yield* withSession({ title: "separator" }).pipe(provideInstance(dir))
// A forward-slash query (e.g. from the SDK/HTTP layer) must still find it —
// this is the regression: backslash-stored vs forward-slash-queried.
const forwardIDs = (yield* SessionNs.Service.use((session) =>
session.list({ directory: dir.replaceAll("\\", "/") }),
)).map((session) => session.id)
expect(forwardIDs).toContain(created.id)
// The native form must keep matching too.
const nativeIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: dir }))).map(
(session) => session.id,
)
expect(nativeIDs).toContain(created.id)
}),
{ git: true },
)
it.instance(
"filters by path and ignores directory when path is provided",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
mkdir(path.join(test.directory, "packages", "opencode", "src", "deep"), { recursive: true }),
)
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
const parent = yield* withSession({ title: "parent" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode")),
)
const current = yield* withSession({ title: "current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode", "src")),
)
const deeper = yield* withSession({ title: "deeper" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode", "src", "deep")),
)
const sibling = yield* withSession({ title: "sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
const pathIDs = (yield* SessionNs.Service.use((session) =>
session.list({
directory: path.join(test.directory, "packages", "app"),
path: "packages/opencode/src",
}),
)).map((session) => session.id)
expect(pathIDs).not.toContain(parent.id)
expect(pathIDs).toContain(current.id)
expect(pathIDs).toContain(deeper.id)
expect(pathIDs).not.toContain(sibling.id)
if (process.platform === "win32") {
const windowsPathIDs = (yield* SessionNs.Service.use((session) =>
session.list({ path: "packages\\opencode\\src" }),
)).map((session) => session.id)
expect(windowsPathIDs).toContain(current.id)
expect(windowsPathIDs).toContain(deeper.id)
}
}),
{ git: true },
)
it.instance(
"falls back to directory when filtering legacy sessions without path",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() =>
mkdir(path.join(test.directory, "packages", "opencode", "src"), { recursive: true }),
)
yield* Effect.promise(() => mkdir(path.join(test.directory, "packages", "app"), { recursive: true }))
const current = yield* withSession({ title: "legacy-current" }).pipe(
provideInstance(path.join(test.directory, "packages", "opencode", "src")),
)
const sibling = yield* withSession({ title: "legacy-sibling" }).pipe(
provideInstance(path.join(test.directory, "packages", "app")),
)
const { db } = yield* Database.Service
yield* db
.update(SessionTable)
.set({ path: null })
.where(eq(SessionTable.id, current.id))
.run()
.pipe(Effect.orDie)
yield* db
.update(SessionTable)
.set({ path: null })
.where(eq(SessionTable.id, sibling.id))
.run()
.pipe(Effect.orDie)
const pathIDs = (yield* SessionNs.Service.use((session) =>
session.list({
directory: path.join(test.directory, "packages", "opencode", "src"),
path: "packages/opencode/src",
}),
)).map((session) => session.id)
expect(pathIDs).toContain(current.id)
expect(pathIDs).not.toContain(sibling.id)
}),
{ git: true },
)
it.instance(
"filters root sessions",
() =>
Effect.gen(function* () {
const root = yield* withSession({ title: "root-session" })
const child = yield* withSession({ title: "child-session", parentID: root.id })
const sessions = yield* SessionNs.use.list({ roots: true })
const ids = sessions.map((session) => session.id)
expect(ids).toContain(root.id)
expect(ids).not.toContain(child.id)
}),
{ git: true },
)
it.instance(
"filters by start time",
() =>
Effect.gen(function* () {
yield* withSession({ title: "new-session" })
const sessions = yield* SessionNs.Service.use((session) => session.list({ start: Date.now() + 86400000 }))
expect(sessions.length).toBe(0)
}),
{ git: true },
)
it.instance(
"filters by search term",
() =>
Effect.gen(function* () {
yield* withSession({ title: "unique-search-term-abc" })
yield* withSession({ title: "other-session-xyz" })
const sessions = yield* SessionNs.use.list({ search: "unique-search" })
const titles = sessions.map((session) => session.title)
expect(titles).toContain("unique-search-term-abc")
expect(titles).not.toContain("other-session-xyz")
}),
{ git: true },
)
it.instance(
"respects limit parameter",
() =>
Effect.gen(function* () {
yield* withSession({ title: "session-1" })
yield* withSession({ title: "session-2" })
yield* withSession({ title: "session-3" })
const sessions = yield* SessionNs.use.list({ limit: 2 })
expect(sessions.length).toBe(2)
}),
{ git: true },
)
it.instance(
"includes metadata in listed sessions",
() =>
Effect.gen(function* () {
const meta = { source: "sdk", trace: { id: "abc" } }
const created = yield* withSession({ title: "meta-session", metadata: meta })
const listed = (yield* SessionNs.Service.use((session) => session.list({ search: "meta-session" }))).find(
(item) => item.id === created.id,
)
expect(listed?.metadata).toEqual(meta)
}),
{ git: true },
)
})

View File

@@ -0,0 +1,179 @@
import { afterEach, describe, expect } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Effect, Layer } from "effect"
import { HttpClientResponse } from "effect/unstable/http"
import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
const model = {
providerID: ProviderV2.ID.make("test"),
modelID: ModelV2.ID.make("test"),
}
afterEach(async () => {
await disposeAllInstances()
})
const withoutWatcher = <A, E, R>(effect: Effect.Effect<A, E, R>) => {
if (process.platform !== "win32") return effect
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true"
return previous
}),
() => effect,
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = previous
}),
)
}
const sessionScoped = Effect.acquireRelease(SessionNs.use.create({}), (session) =>
SessionNs.use.remove(session.id).pipe(Effect.ignore),
)
const fill = Effect.fn("SessionMessagesTest.fill")(function* (
sessionID: SessionID,
count: number,
time = (i: number) => Date.now() + i,
) {
const session = yield* SessionNs.Service
return yield* Effect.forEach(
Array.from({ length: count }, (_, i) => i),
(i) =>
Effect.gen(function* () {
const id = MessageID.ascending()
yield* session.updateMessage({
id,
sessionID,
role: "user",
time: { created: time(i) },
agent: "test",
model,
tools: {},
} satisfies SessionV1.User)
yield* session.updatePart({
id: PartID.ascending(),
sessionID,
messageID: id,
type: "text",
text: `m${i}`,
} satisfies SessionV1.TextPart)
return id
}),
)
})
function request(path: string) {
return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory)))
}
function json<T>(response: HttpClientResponse.HttpClientResponse) {
return response.json.pipe(Effect.map((body) => body as T))
}
describe("session messages endpoint", () => {
it.instance(
"returns cursor headers for older pages",
withoutWatcher(
Effect.gen(function* () {
const session = yield* sessionScoped
const ids = yield* fill(session.id, 5)
const a = yield* request(`/session/${session.id}/message?limit=2`)
expect(a.status).toBe(200)
const aBody = yield* json<SessionV1.WithParts[]>(a)
expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2))
const cursor = a.headers["x-next-cursor"]
expect(cursor).toBeTruthy()
expect(a.headers["link"]).toContain('rel="next"')
const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`)
expect(b.status).toBe(200)
const bBody = yield* json<SessionV1.WithParts[]>(b)
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
}),
),
{ git: true },
)
it.instance(
"keeps full-history responses when limit is omitted",
withoutWatcher(
Effect.gen(function* () {
const session = yield* sessionScoped
const ids = yield* fill(session.id, 3)
const res = yield* request(`/session/${session.id}/message`)
expect(res.status).toBe(200)
const body = yield* json<SessionV1.WithParts[]>(res)
expect(body.map((item) => item.info.id)).toEqual(ids)
}),
),
{ git: true },
)
it.instance(
"rejects invalid cursors and missing sessions",
withoutWatcher(
Effect.gen(function* () {
const session = yield* sessionScoped
const bad = yield* request(`/session/${session.id}/message?limit=2&before=bad`)
expect(bad.status).toBe(400)
const miss = yield* request(`/session/ses_missing/message?limit=2`)
expect(miss.status).toBe(404)
}),
),
{ git: true },
)
it.instance(
"does not truncate large legacy limit requests",
withoutWatcher(
Effect.gen(function* () {
const session = yield* sessionScoped
yield* fill(session.id, 520)
const res = yield* request(`/session/${session.id}/message?limit=510`)
expect(res.status).toBe(200)
const body = yield* json<SessionV1.WithParts[]>(res)
expect(body).toHaveLength(510)
}),
),
{ git: true },
)
it.instance(
"accepts directory query used by workspace routing",
withoutWatcher(
Effect.gen(function* () {
const tmp = yield* TestInstance
const session = yield* sessionScoped
yield* fill(session.id, 1)
const res = yield* request(
`/session/${session.id}/message?limit=80&directory=${encodeURIComponent(tmp.directory)}`,
)
expect(res.status).toBe(200)
const body = yield* json<unknown[]>(res)
expect(Array.isArray(body)).toBe(true)
expect(body).toHaveLength(1)
}),
),
{ git: true },
)
})

View File

@@ -0,0 +1,66 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Session } from "@/session/session"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
describe("tui.selectSession endpoint", () => {
it.instance(
"should return 200 when called with valid session",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const session = yield* Session.use.create({})
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(response.status).toBe(200)
const body = yield* response.json
expect(body).toBe(true)
}),
{ git: true },
)
it.instance(
"should return 404 when session does not exist",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const nonExistentSessionID = "ses_nonexistent123"
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: nonExistentSessionID }),
})
expect(response.status).toBe(404)
}),
{ git: true },
)
it.instance(
"should return 400 when session ID format is invalid",
() =>
Effect.gen(function* () {
const tmp = yield* TestInstance
const invalidSessionID = "invalid_session_id"
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID: invalidSessionID }),
})
expect(response.status).toBe(400)
}),
{ git: true },
)
})

View File

@@ -0,0 +1,181 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import Http from "node:http"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue } from "effect"
import { FetchHttpClient, HttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy"
import { testEffect } from "../lib/effect"
function serverUrl() {
return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
}
const testServerLayer = Layer.mergeAll(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
NodeServices.layer,
FetchHttpClient.layer,
Socket.layerWebSocketConstructorGlobal,
)
const it = testEffect(testServerLayer)
type TestHandler<E, R> = (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
function listenServer<E, R>(handler: TestHandler<E, R>) {
return Effect.gen(function* () {
yield* HttpServer.serveEffect()(HttpServerRequest.HttpServerRequest.use(handler))
return yield* serverUrl()
})
}
function listenTestServer<E, R>(handler: TestHandler<E, R>) {
return Effect.gen(function* () {
// Build into the current test scope so the listener stays alive until the
// test finishes. Using Effect.provide here would release it immediately.
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)
})
}
function echoWebSocket(request: HttpServerRequest.HttpServerRequest) {
return Effect.gen(function* () {
const socket = yield* Effect.orDie(request.upgrade)
const write = yield* socket.writer
// 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:${String(message)}`), {
onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
Effect.catch(() => Effect.void),
),
})
.pipe(Effect.catch(() => Effect.void))
return HttpServerResponse.empty()
})
}
describe("HttpApi workspace proxy", () => {
it.live("proxies HTTP request and returns streamed response with status and headers", () =>
Effect.gen(function* () {
const url = yield* listenServer(
Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) {
const body = yield* req.text
return yield* HttpServerResponse.json(
{ path: req.url, method: req.method, body },
{
status: 201,
headers: {
"content-encoding": "identity",
"content-length": "999",
"x-remote": "yes",
},
},
)
}),
)
const request = HttpServerRequest.fromWeb(
new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }),
)
const httpClient = yield* HttpClient.HttpClient
const response = yield* HttpApiProxy.http(
httpClient,
`${url}/session/abc?keep=yes`,
{ "x-extra": "injected" },
request,
)
expect(response.status).toBe(201)
const client = HttpServerResponse.toClientResponse(response)
expect(yield* client.json).toEqual({
path: "/session/abc?keep=yes",
method: "POST",
body: "request-body",
})
expect(response.headers["x-remote"]).toBe("yes")
expect(response.headers["content-encoding"]).toBeUndefined()
expect(response.headers["content-length"]).toBeUndefined()
}),
)
it.live("returns 500 when remote is unreachable", () =>
Effect.gen(function* () {
const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything"))
const httpClient = yield* HttpClient.HttpClient
const response = yield* HttpApiProxy.http(httpClient, "http://127.0.0.1:1/unreachable", undefined, request)
expect(response.status).toBe(500)
}),
)
it.live("proxies bodyless Web mutation requests as an empty body", () =>
Effect.gen(function* () {
const url = yield* listenServer(
Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) {
return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text })
}),
)
const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" }))
const httpClient = yield* HttpClient.HttpClient
const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request)
expect(response.status).toBe(200)
expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" })
}),
)
it.live("strips opencode-internal headers and merges extra headers", () =>
Effect.gen(function* () {
let forwarded: Record<string, string> = {}
const url = yield* listenServer((req) =>
Effect.sync(() => {
forwarded = req.headers
return HttpServerResponse.empty()
}),
)
const request = HttpServerRequest.fromWeb(
new Request("http://localhost/test", {
headers: {
"x-opencode-directory": "/secret/path",
"x-opencode-workspace": "ws_123",
"x-custom": "preserved",
},
}),
)
const httpClient = yield* HttpClient.HttpClient
yield* HttpApiProxy.http(httpClient, `${url}/test`, { "x-injected": "extra" }, request)
expect(forwarded["x-opencode-directory"]).toBeUndefined()
expect(forwarded["x-opencode-workspace"]).toBeUndefined()
expect(forwarded["x-custom"]).toBe("preserved")
expect(forwarded["x-injected"]).toBe("extra")
}),
)
it.live("proxies websocket messages and protocols", () =>
Effect.gen(function* () {
const upstreamUrl = yield* listenTestServer(echoWebSocket)
// Client -> proxy listener -> HttpApiProxy.websocket -> upstream listener.
// The client never connects to upstream directly.
const proxyUrl = yield* listenServer((request) => HttpApiProxy.websocket(request, `${upstreamUrl}/echo`))
const socket = yield* Socket.makeWebSocket(`${proxyUrl.replace(/^http/, "ws")}/proxy`, {
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")
}),
)
})

View File

@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test"
import {
isLocalWorkspaceRoute,
getWorkspaceRouteSessionID,
workspaceProxyURL,
} from "../../src/server/shared/workspace-routing"
import { SessionID } from "../../src/session/schema"
describe("isLocalWorkspaceRoute", () => {
test("GET /session is local", () => {
expect(isLocalWorkspaceRoute("GET", "/session")).toBe(true)
})
test("GET /session/ses_abc is local (prefix match)", () => {
expect(isLocalWorkspaceRoute("GET", "/session/ses_abc")).toBe(true)
})
test("POST /session is not local (method mismatch)", () => {
expect(isLocalWorkspaceRoute("POST", "/session")).toBe(false)
})
test("/session/status is forwarded regardless of method", () => {
expect(isLocalWorkspaceRoute("GET", "/session/status")).toBe(false)
expect(isLocalWorkspaceRoute("POST", "/session/status")).toBe(false)
})
test("unrecognized paths are not local", () => {
expect(isLocalWorkspaceRoute("GET", "/config")).toBe(false)
expect(isLocalWorkspaceRoute("POST", "/session/ses_abc/message")).toBe(false)
})
})
describe("getWorkspaceRouteSessionID", () => {
test("extracts session ID from path", () => {
const url = new URL("http://localhost/session/ses_abc123/message")
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_abc123"))
})
test("extracts session ID without trailing path", () => {
const url = new URL("http://localhost/session/ses_xyz")
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz"))
})
test("extracts session ID from experimental background path", () => {
const url = new URL("http://localhost/experimental/session/ses_bg/background")
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_bg"))
})
test("returns null for /session/status", () => {
const url = new URL("http://localhost/session/status")
expect(getWorkspaceRouteSessionID(url)).toBeNull()
})
test("returns null for non-session paths", () => {
const url = new URL("http://localhost/config")
expect(getWorkspaceRouteSessionID(url)).toBeNull()
})
test("returns null for bare /session path", () => {
const url = new URL("http://localhost/session")
expect(getWorkspaceRouteSessionID(url)).toBeNull()
})
})
describe("workspaceProxyURL", () => {
test("appends request path to target", () => {
const result = workspaceProxyURL("http://remote:8080/base", new URL("http://localhost/config"))
expect(result.toString()).toBe("http://remote:8080/base/config")
})
test("strips trailing slash on target before appending", () => {
const result = workspaceProxyURL("http://remote:8080/base/", new URL("http://localhost/session/abc"))
expect(result.pathname).toBe("/base/session/abc")
})
test("preserves query params from request but removes workspace", () => {
const url = new URL("http://localhost/config?workspace=ws_123&keep=yes")
const result = workspaceProxyURL("http://remote:8080/base", url)
expect(result.searchParams.get("workspace")).toBeNull()
expect(result.searchParams.get("keep")).toBe("yes")
})
test("preserves hash from request", () => {
const url = new URL("http://localhost/page#section")
const result = workspaceProxyURL("http://remote:8080", url)
expect(result.hash).toBe("#section")
})
test("works with URL object as target", () => {
const target = new URL("http://remote:3000/api")
const result = workspaceProxyURL(target, new URL("http://localhost/users"))
expect(result.toString()).toBe("http://remote:3000/api/users")
})
})

View File

@@ -0,0 +1,307 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Worktree } from "@/worktree"
import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const stateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const original = {
OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
}
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES
await resetDatabase()
}),
)
}),
)
const it = testEffect(stateLayer)
const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance
type TestServer = ReturnType<typeof Server.Default>["app"]
type CreatedWorktree = { directory: string }
type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect<void, Error> }
function serverScoped() {
return Effect.sync(() => Server.Default().app)
}
function request(server: TestServer, input: string, init?: RequestInit) {
return Effect.promise(() => Promise.resolve(server.request(input, init)))
}
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
return effect.pipe(
Effect.timeoutOrElse({
duration: `${ms} millis`,
orElse: () => Effect.fail(new Error(`${label} timed out after ${ms}ms`)),
}),
)
}
function json<T>(response: Response) {
return Effect.promise(() => response.json() as Promise<T>)
}
function readyWatcher() {
return Effect.gen(function* () {
const events = yield* Queue.bounded<GlobalEvent>(1)
const on = (event: GlobalEvent) => {
if (event.payload.type === Worktree.Event.Ready.type) Queue.offerUnsafe(events, event)
}
GlobalBus.on("event", on)
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
return (directory: string) =>
Effect.gen(function* () {
while (true) {
const event = yield* Queue.take(events)
if (event.directory === directory) return
}
}).pipe(
Effect.timeoutOrElse({
duration: "10 seconds",
orElse: () => Effect.fail(new Error(`timed out waiting for worktree.ready: ${directory}`)),
}),
)
})
}
function removeCreatedWorktree(input: {
server: TestServer
rootDirectory: string
worktreeDirectory: string
ready: Effect.Effect<void, Error>
}) {
return Effect.gen(function* () {
yield* input.ready.pipe(Effect.timeout("1 second"), Effect.ignore)
yield* Effect.promise(() => disposeAllInstances()).pipe(Effect.ignore)
const removed = yield* request(
input.server,
`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(input.rootDirectory)}`,
{
method: "DELETE",
headers: { "content-type": "application/json" },
body: JSON.stringify({ directory: input.worktreeDirectory }),
},
)
if (removed.status !== 200) {
const message = yield* Effect.promise(() => removed.text())
throw new Error(`failed to remove worktree: ${removed.status} ${message}`)
}
const ok = yield* json<boolean>(removed)
if (!ok) throw new Error(`failed to remove worktree ${input.worktreeDirectory}`)
})
}
function createWorktreeScoped(input: {
server: TestServer
directory: string
path: string
init: RequestInit
timeoutLabel: string
timeoutMs?: number
}) {
return Effect.acquireRelease(
Effect.gen(function* () {
const waitReady = yield* readyWatcher()
const response = yield* withRequestTimeout(
request(input.server, input.path, input.init),
input.timeoutLabel,
input.timeoutMs,
)
if (response.status !== 200) {
const message = yield* Effect.promise(() => response.text())
throw new Error(`${input.timeoutLabel} failed: ${response.status} ${message}`)
}
expect(response.status).toBe(200)
const body = yield* json<CreatedWorktree>(response)
return { directory: body.directory, body, ready: waitReady(body.directory) } satisfies ScopedWorktree
}),
(created) =>
removeCreatedWorktree({
server: input.server,
rootDirectory: input.directory,
worktreeDirectory: created.directory,
ready: created.ready,
}).pipe(Effect.orDie),
).pipe(Effect.map((created) => created.body))
}
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
return Effect.gen(function* () {
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
expect(current.status).toBe(200)
const project = yield* json<{ id: string }>(current)
const updated = yield* request(
input.server,
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ commands: { start: input.command } }),
},
)
expect(updated.status).toBe(200)
})
}
describe("worktree endpoint reproduction", () => {
worktreeTest(
"direct HttpApi worktree create returns without waiting for boot",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
const response = yield* createWorktreeScoped({
server,
directory: test.directory,
path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
init: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
},
timeoutLabel: "direct worktree create",
})
expect(response).toMatchObject({ directory: expect.any(String) })
}),
{ git: true },
)
worktreeTest(
"direct HttpApi worktree create accepts missing body",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
const response = yield* createWorktreeScoped({
server,
directory: test.directory,
path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
init: { method: "POST", headers: { "content-type": "application/json" } },
timeoutLabel: "direct worktree create without body",
})
expect(response).toMatchObject({ directory: expect.any(String) })
}),
{ git: true },
)
worktreeTest(
"direct HttpApi worktree create accepts missing content type and body",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
const response = yield* createWorktreeScoped({
server,
directory: test.directory,
path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
init: { method: "POST" },
timeoutLabel: "direct worktree create without content type or body",
})
expect(response).toMatchObject({ directory: expect.any(String) })
}),
{ git: true },
)
worktreeTest(
"direct HttpApi worktree create rejects explicit null payload",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
const response = yield* request(
server,
`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: "null",
},
)
expect(response.status).toBe(400)
}),
{ git: true },
)
worktreeTest(
"workspace worktree create does not hang",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
const response = yield* createWorktreeScoped({
server,
directory: test.directory,
path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
init: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null }),
},
timeoutLabel: "workspace worktree create",
timeoutMs: 8_000,
})
expect(response).toMatchObject({
type: "worktree",
directory: expect.any(String),
})
}),
{ git: true },
)
worktreeTest(
"workspace worktree create returns without waiting for project start command",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
yield* setProjectStartCommand({
server,
directory: test.directory,
command: 'bun -e "setTimeout(() => {}, 2000)"',
})
const started = Date.now()
yield* createWorktreeScoped({
server,
directory: test.directory,
path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
init: {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "worktree", branch: null }),
},
timeoutLabel: "workspace worktree create with project start command",
timeoutMs: 6_000,
})
expect(Date.now() - started).toBeLessThan(1_500)
}),
{ git: true },
)
})