mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-13 23:52:06 +00:00
This reverts: -28b03595bresearch: delete Hono backend (do not merge) (#25667) -b24a4e897chore(server): clean up post-Hono-deletion scar tissue (#26542) v1.14.42 broke startup for users with plugins that depend on the Hono wire format (most visibly opencode-gemini-auth, see #26546). Restoring Hono as the default backend on stable channels while we investigate the actual plugin compatibility story. OPENCODE_EXPERIMENTAL_HTTPAPI flag and dual-backend selection come back. Stable installs default to Hono; dev/beta default to HTTP API. Conflict resolution: took the pre-deletion side for control-plane schemas and the seven test files where post-deletion follow-up PRs had also touched the conflicting lines. The HTTP API code added since the deletion (compression, cors-vary, fence, lifecycle log, account error mapping, etc.) is preserved as-is — those still apply on the HTTP API path for users on dev/beta channels.
192 lines
7.6 KiB
TypeScript
192 lines
7.6 KiB
TypeScript
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 { WorkspaceID } from "../../src/control-plane/schema"
|
|
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 { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
|
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
|
import { resetDatabase } from "../fixture/db"
|
|
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
// Flip the experimental HttpApi flag so backend selection telemetry on the
|
|
// production routes reports the right backend, and the experimental
|
|
// workspaces flag so SyncEvent.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 originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
|
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
|
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
|
yield* Effect.promise(() => resetDatabase())
|
|
yield* Effect.addFinalizer(() =>
|
|
Effect.promise(async () => {
|
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
|
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 through the same route layer production uses, without
|
|
// going through Server.Default()/Hono.
|
|
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
|
ExperimentalHttpApiServer.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 = WorkspaceID.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 = WorkspaceID.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(() =>
|
|
ExperimentalHttpApiServer.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("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" }),
|
|
)
|
|
}),
|
|
)
|
|
})
|