resolve merge conflicts

This commit is contained in:
Mark IJbema
2026-05-06 21:32:26 +02:00
249 changed files with 10440 additions and 4916 deletions
+15
View File
@@ -0,0 +1,15 @@
# Server Test Guide
Use these patterns for server and HttpApi middleware tests in this directory.
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
- Use scoped test layers for flags, database reset, and other global mutable state. Restore flags and reset state in finalizers.
- Use `tmpdirScoped({ git: true })` plus `Project.use.fromDirectory(dir)` for project-backed requests.
- If a test needs persisted state without matching runtime state, keep direct database setup inside a narrowly named helper that explains that state.
- Add comments for non-obvious test topology, especially tests involving both the local test server and a fake upstream server.
@@ -0,0 +1,103 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import {
Authorization,
ServerAuthConfig,
authorizationLayer,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { testEffect } from "../lib/effect"
const Api = HttpApi.make("test-authorization").add(
HttpApiGroup.make("test")
.add(
HttpApiEndpoint.get("probe", "/probe", {
success: Schema.String,
}),
)
.middleware(Authorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
const basic = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
const getProbe = (headers?: Record<string, string>) =>
HttpClientRequest.get("/probe").pipe(
headers ? HttpClientRequest.setHeaders(headers) : (request) => request,
HttpClient.execute,
)
describe("HttpApi authorization middleware", () => {
it.live("allows requests when server password is not configured", () =>
Effect.gen(function* () {
const response = yield* getProbe()
expect(response.status).toBe(200)
expect(yield* response.json).toBe("ok")
}),
)
itSecret.live("requires configured password for basic auth", () =>
Effect.gen(function* () {
const [missing, badPassword, good] = yield* Effect.all(
[
getProbe(),
getProbe({ authorization: basic("opencode", "wrong") }),
getProbe({ authorization: basic("opencode", "secret") }),
],
{ concurrency: "unbounded" },
)
expect(missing.status).toBe(401)
expect(badPassword.status).toBe(401)
expect(good.status).toBe(200)
}),
)
itKitSecret.live("respects configured basic auth username", () =>
Effect.gen(function* () {
const [defaultUser, configuredUser] = yield* Effect.all(
[getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })],
{ concurrency: "unbounded" },
)
expect(defaultUser.status).toBe(401)
expect(configuredUser.status).toBe(200)
}),
)
itSecret.live("accepts auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
expect(response.status).toBe(200)
}),
)
itSecret.live("rejects malformed auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
expect(response.status).toBe(401)
}),
)
})
@@ -2,14 +2,17 @@ import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -30,7 +33,26 @@ function app(input?: { password?: string; username?: string }) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_SERVER_PASSWORD = input?.password
Flag.KILO_SERVER_USERNAME = input?.username
return Server.Default().app
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input?.password,
KILO_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function openApiRouteKeys(spec: { paths: Record<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
@@ -94,10 +116,26 @@ type RequestBody = {
required?: boolean
}
function parameterKey(param: unknown) {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return
if (typeof param.in !== "string" || typeof param.name !== "string") return
return `${param.in}:${param.name}:${"required" in param && param.required === true}`
function parameterKey(param: unknown): string | undefined {
if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined
if (typeof param.in !== "string" || typeof param.name !== "string") return undefined
return `${param.in}:${param.name}:${"required" in param && param.required === true}:${stableSchema(
"schema" in param ? param.schema : undefined,
)}`
}
function stableSchema(input: unknown): string {
return JSON.stringify(sortSchema(input))
}
function sortSchema(input: unknown): unknown {
if (Array.isArray(input)) return input.map(sortSchema)
if (!input || typeof input !== "object") return input
return Object.fromEntries(
Object.entries(input)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => [key, sortSchema(value)]),
)
}
function parameterSchema(input: {
@@ -105,27 +143,29 @@ function parameterSchema(input: {
path: string
method: (typeof methods)[number]
name: string
}) {
}): unknown {
const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find(
(param) => !!param && typeof param === "object" && "name" in param && param.name === input.name,
)
if (!param || typeof param !== "object" || !("schema" in param)) return
if (!param || typeof param !== "object" || !("schema" in param)) return undefined
return param.schema
}
function requestBodyKey(spec: OpenApiSpec, body: unknown) {
if (!body || typeof body !== "object" || !("content" in body)) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
const requestBody = body as RequestBody
return JSON.stringify({
required: requestBody.required === true,
content: Object.entries(requestBody.content ?? {})
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)])
.sort(),
.map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const)
.sort(([left], [right]) => left.localeCompare(right)),
})
}
function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) {
if (!schema) return ""
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
const resolved = (
schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema
) as OpenApiSchema | undefined
@@ -142,6 +182,7 @@ function responseContentTypes(input: {
}) {
const responses = input.spec.paths[input.path]?.[input.method]?.responses
if (!responses || typeof responses !== "object" || !(input.status in responses)) return []
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
const response = (responses as Record<string, unknown>)[input.status]
if (!response || typeof response !== "object" || !("content" in response)) return []
const content = (response as { content?: unknown }).content
@@ -167,7 +208,7 @@ afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI
Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD
Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -240,6 +281,18 @@ describe("HttpApi server", () => {
})
})
test("matches SDK-affecting request schema details", () => {
const effect = effectOpenApi()
const sessionUpdate = effect.paths["/session/{sessionID}"]?.patch?.requestBody
const sessionUpdateSchema =
typeof sessionUpdate === "object" && sessionUpdate && "content" in sessionUpdate
? sessionUpdate.content?.["application/json"]?.schema
: undefined
const sessionUpdateProperties = sessionUpdateSchema?.properties as Record<string, OpenApiSchema> | undefined
const time = sessionUpdateProperties?.time
expect(time?.properties?.archived).toEqual({ type: "number" })
})
test("documents event routes as server-sent events", () => {
const effect = effectOpenApi()
@@ -6,7 +6,7 @@ import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -37,7 +37,7 @@ async function waitDisposed(directory: string) {
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -0,0 +1,89 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Config, 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 { ExperimentalHttpApiServer } 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 = {
KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI,
KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD,
}
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_SERVER_PASSWORD = "secret"
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI
Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD
await resetDatabase()
}),
)
}),
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
ExperimentalHttpApiServer.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("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")
}),
)
})
@@ -5,15 +5,15 @@ import { Server } from "../../src/server/server"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
function app() {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
async function readFirstChunk(response: Response) {
@@ -29,7 +29,7 @@ async function readFirstChunk(response: Response) {
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -45,4 +45,13 @@ describe("event HttpApi bridge", () => {
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
expect(await readFirstChunk(response)).toContain('data: {"type":"server.connected","properties":{}}\n\n')
})
test("matches legacy first event frame", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path }
const legacy = await app(false).request(EventPaths.event, { headers })
const effect = await app(true).request(EventPaths.event, { headers })
expect(await readFirstChunk(effect)).toBe(await readFirstChunk(legacy))
})
})
@@ -10,7 +10,7 @@ import { Database } from "@/storage/db"
import * as Log from "@opencode-ai/core/util/log"
import { Worktree } from "../../src/worktree"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -50,7 +50,7 @@ async function waitReady(directory: string) {
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -6,7 +6,7 @@ import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { Instance } from "../../src/project/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -28,7 +28,7 @@ function request(route: string, directory: string, query?: Record<string, string
}
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -0,0 +1,237 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceBootstrap } from "../../src/project/bootstrap"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Project } from "../../src/project/project"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES
yield* Effect.promise(() => resetDatabase())
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
}),
)
}),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
InstanceBootstrap.defaultLayer,
InstanceStore.defaultLayer,
Project.defaultLayer,
Workspace.defaultLayer,
),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.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.Service.use((workspace) => workspace.remove(info.id)).pipe(Effect.ignore),
)
const probeInstanceContext = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return yield* HttpServerResponse.json({
directory: instance?.directory,
worktree: instance?.worktree,
projectID: instance?.project.id,
workspaceID,
})
})
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
Layer.provide(instanceContextTestLayer),
HttpRouter.serve,
Layer.build,
)
const waitDisposedEvent = Effect.promise(
() =>
new Promise<{ directory?: string; workspace?: string }>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; workspace?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed") return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve({ directory: event.directory, workspace: event.workspace })
}
GlobalBus.on("event", onEvent)
}),
)
const serveDisposeProbe = () =>
HttpRouter.serve(
HttpRouter.add(
"POST",
"/dispose-probe",
Effect.gen(function* () {
const instance = yield* InstanceRef
if (!instance) return HttpServerResponse.empty({ status: 500 })
yield* markInstanceForDisposal(instance)
return yield* HttpServerResponse.json(true)
}),
).pipe(Layer.provide(instanceContextTestLayer)),
{ 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,
})
}),
)
it.live("falls back to the raw directory when URI decoding fails", () =>
Effect.gen(function* () {
yield* serveProbe()
const response = yield* HttpClient.get("/probe?directory=%25E0%25A4%25A")
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({
directory: path.join(process.cwd(), "%E0%A4%A"),
})
}),
)
it.live("provides selected workspace id on control-plane routes", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "instance-context-workspace-ref",
directory: workspaceDir,
})
yield* serveProbe("/session")
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-kilo-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-kilo-directory", dir),
HttpClient.execute,
)
expect(response.status).toBe(200)
expect(yield* response.json).toMatchObject({
directory: workspaceDir,
workspaceID: workspace.id,
})
}),
)
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)
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 })
}),
)
})
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
function app() {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
})
}
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
describe("instance HttpApi", () => {
test("serves catalog read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const [commands, agents, skills, lsp, formatter] = await Promise.all([
app().request(InstancePaths.command, { headers: { "x-kilo-directory": tmp.path } }),
app().request(InstancePaths.agent, { headers: { "x-kilo-directory": tmp.path } }),
app().request(InstancePaths.skill, { headers: { "x-kilo-directory": tmp.path } }),
app().request(InstancePaths.lsp, { headers: { "x-kilo-directory": tmp.path } }),
app().request(InstancePaths.formatter, { headers: { "x-kilo-directory": tmp.path } }),
])
expect(commands.status).toBe(200)
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
expect(agents.status).toBe(200)
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
expect(skills.status).toBe(200)
expect(await skills.json()).toBeArray()
expect(lsp.status).toBe(200)
expect(await lsp.json()).toEqual([])
expect(formatter.status).toBe(200)
expect(await formatter.json()).toEqual([])
})
test("serves project git init through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const disposed = waitDisposed(tmp.path)
const response = await app().request("/project/git/init", {
method: "POST",
headers: { "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
await disposed
const current = await app().request("/project/current", { headers: { "x-kilo-directory": tmp.path } })
expect(current.status).toBe(200)
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
})
test("serves project update through Hono bridge", async () => {
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
const current = await app().request("/project/current", { headers: { "x-kilo-directory": tmp.path } })
expect(current.status).toBe(200)
const project = (await current.json()) as { id: string }
const response = await app().request(`/project/${project.id}`, {
method: "PATCH",
headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
id: project.id,
name: "patched-project",
commands: { start: "bun dev" },
})
const list = await app().request("/project", { headers: { "x-kilo-directory": tmp.path } })
expect(list.status).toBe(200)
expect(await list.json()).toContainEqual(
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
)
})
test("serves instance dispose through Hono bridge", async () => {
await using tmp = await tmpdir()
const disposed = new Promise<string | undefined>((resolve) => {
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
if (event.payload.type !== "server.instance.disposed") return
GlobalBus.off("event", onEvent)
resolve(event.directory)
}
GlobalBus.on("event", onEvent)
})
const response = await app().request(InstancePaths.dispose, {
method: "POST",
headers: { "x-kilo-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path)
})
})
@@ -1,48 +1,121 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Config, Effect, FileSystem, Layer, Path } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
// kilocode_change start - Hono-bridge tests still cover routes that haven't migrated to the Effect HttpApi
import { GlobalBus } from "@/bus/global"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
// kilocode_change end
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
// Flip the experimental HttpApi flag so backend selection telemetry on the
// production routes reports the right backend, and 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.KILO_EXPERIMENTAL_HTTPAPI
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = originalHttpApi
await resetDatabase()
}),
)
}),
)
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
// 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 },
)
function app() {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
const httpApiServerLayer = servedRoutes.pipe(
Layer.provide(Socket.layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
)
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
const it = testEffect(Layer.mergeAll(testStateLayer, httpApiServerLayer))
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
})
}
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-kilo-directory", dir)
describe("instance HttpApi", () => {
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" }),
)
}),
)
// kilocode_change start - Hono-bridge tests cover routes still served via Server.Default()
function app() {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
async function waitDisposed(directory: string) {
return await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", onEvent)
reject(new Error("timed out waiting for instance disposal"))
}, 10_000)
function onEvent(event: { directory?: string; payload: { type?: string } }) {
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
clearTimeout(timer)
GlobalBus.off("event", onEvent)
resolve()
}
GlobalBus.on("event", onEvent)
})
}
afterEach(async () => {
await disposeAllInstances()
await resetDatabase()
})
test("serves path and VCS read endpoints through Hono bridge", async () => {
await using tmp = await tmpdir({ git: true })
await Bun.write(path.join(tmp.path, "changed.txt"), "hello")
@@ -68,7 +141,7 @@ describe("instance HttpApi", () => {
)
})
// kilocode_change - skip until Kilo's Instance context threads through the Effect HttpApi bridge.
// skip until Kilo's Instance context threads through the Effect HttpApi bridge.
// The /agent handler 500s via the bridge (agent.list's InstanceState lookup drops context mid-request).
// Bridge is gated behind KILO_EXPERIMENTAL_HTTPAPI, not enabled in any production client.
test.skip("serves catalog read endpoints through Hono bridge", async () => {
@@ -164,4 +237,5 @@ describe("instance HttpApi", () => {
expect(await response.json()).toBe(true)
expect(await disposed).toBe(tmp.path)
})
// kilocode_change end
})
@@ -5,12 +5,17 @@ import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { MessageID, PartID } from "../../src/session/schema"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
void Log.init({ print: false })
@@ -84,11 +89,94 @@ function expectJsonParity(input: {
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
describe("HttpApi JSON parity", () => {
it.live(
"matches legacy JSON shape for safe GET endpoints",
withTmp(
{
git: true,
config: {
formatter: false,
lsp: false,
mcp: {
demo: {
type: "local",
command: ["echo", "demo"],
enabled: false,
},
},
},
},
(tmp) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(`${tmp.path}/hello.txt`, "hello\n"))
const headers = { "x-kilo-directory": tmp.path }
const legacy = app(false)
const httpapi = app(true)
yield* Effect.forEach(
[
{ label: "global.health", path: GlobalPaths.health, headers: {} },
{ label: "global.config", path: GlobalPaths.config, headers: {} },
{ label: "instance.path", path: InstancePaths.path, headers },
{ label: "instance.vcs", path: InstancePaths.vcs, headers },
{ label: "instance.vcsDiff", path: `${InstancePaths.vcsDiff}?mode=git`, headers },
{ label: "instance.command", path: InstancePaths.command, headers },
{ label: "instance.agent", path: InstancePaths.agent, headers },
{ label: "instance.skill", path: InstancePaths.skill, headers },
{ label: "instance.lsp", path: InstancePaths.lsp, headers },
{ label: "instance.formatter", path: InstancePaths.formatter, headers },
{ label: "config.get", path: "/config", headers },
{ label: "config.providers", path: "/config/providers", headers },
{ label: "project.list", path: "/project", headers },
{ label: "project.current", path: "/project/current", headers },
{ label: "provider.list", path: "/provider", headers },
{ label: "provider.auth", path: "/provider/auth", headers },
{ label: "permission.list", path: "/permission", headers },
{ label: "question.list", path: "/question", headers },
{ label: "mcp.status", path: McpPaths.status, headers },
{ label: "pty.shells", path: PtyPaths.shells, headers },
{ label: "pty.list", path: PtyPaths.list, headers },
{ label: "file.list", path: `${FilePaths.list}?${new URLSearchParams({ path: "." })}`, headers },
{
label: "file.content",
path: `${FilePaths.content}?${new URLSearchParams({ path: "hello.txt" })}`,
headers,
},
{ label: "file.status", path: FilePaths.status, headers },
{
label: "find.file",
path: `${FilePaths.findFile}?${new URLSearchParams({ query: "hello", dirs: "false" })}`,
headers,
},
{
label: "find.text",
path: `${FilePaths.findText}?${new URLSearchParams({ pattern: "hello" })}`,
headers,
},
{
label: "find.symbol",
path: `${FilePaths.findSymbol}?${new URLSearchParams({ query: "hello" })}`,
headers,
},
{ label: "experimental.console", path: ExperimentalPaths.console, headers },
{ label: "experimental.consoleOrgs", path: ExperimentalPaths.consoleOrgs, headers },
{ label: "experimental.toolIDs", path: ExperimentalPaths.toolIDs, headers },
{ label: "experimental.worktree", path: ExperimentalPaths.worktree, headers },
{ label: "experimental.resource", path: ExperimentalPaths.resource, headers },
],
(input) => expectJsonParity({ ...input, legacy, httpapi }),
{ concurrency: 1 },
)
}),
),
)
it.live(
"matches legacy JSON shape for session read endpoints",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
@@ -0,0 +1,76 @@
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({
basic: (effect) => effect,
authToken: (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",
})
}),
)
})
@@ -5,10 +5,11 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { McpPaths } from "../../src/server/routes/instance/httpapi/groups/mcp"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
@@ -57,7 +58,9 @@ function withMcpProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>)
}),
)
yield* Effect.addFinalizer(() =>
Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore),
Effect.promise(() =>
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
).pipe(Effect.ignore),
)
return yield* self(dir).pipe(provideInstance(dir))
@@ -76,7 +79,7 @@ const readResponse = Effect.fnUntraced(function* (input: { app: TestApp; path: s
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -3,10 +3,11 @@ import { Effect, FileSystem, Layer, Path } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { InstanceStore } from "../../src/project/instance-store"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { provideInstance } from "../fixture/fixture"
import { disposeAllInstances, provideInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
@@ -89,7 +90,9 @@ function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
)
yield* writeProviderAuthPlugin(dir)
yield* Effect.addFinalizer(() =>
Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore),
Effect.promise(() =>
Instance.provide({ directory: dir, fn: () => InstanceStore.disposeInstance(Instance.current) }),
).pipe(Effect.ignore),
)
return yield* self(dir).pipe(provideInstance(dir))
@@ -98,7 +101,7 @@ function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E,
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { handlePtyInput } from "../../src/pty/input"
describe("pty HttpApi websocket input", () => {
test("does not forward invalid binary frames to the PTY handler", async () => {
const messages: Array<string | ArrayBuffer> = []
const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) }
await Effect.runPromise(handlePtyInput(handler, "ready"))
await Effect.runPromise(handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd])))
await Effect.runPromise(handlePtyInput(handler, new TextEncoder().encode("hello")))
expect(messages).toEqual(["ready", "hello"])
})
})
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, test } from "bun:test"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { PtyID } from "../../src/pty/schema"
import { Instance } from "../../src/project/instance"
@@ -6,21 +7,63 @@ import { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
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 { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Pty } from "../../src/pty"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
const testPty = process.platform === "win32" ? test.skip : test
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await resetDatabase()
}),
)
}),
)
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
ExperimentalHttpApiServer.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() {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
}
function serverUrl() {
return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
}
const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-kilo-directory", dir)
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -85,4 +128,48 @@ describe("pty HttpApi bridge", () => {
})
expect(response.status).toBe(404)
})
;(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)
}),
)
})
@@ -0,0 +1,89 @@
import { afterEach, describe, expect, test } from "bun:test"
import { ConfigProvider, Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { PtyID } from "../../src/pty/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import * as Log from "@opencode-ai/core/util/log"
void Log.init({ print: false })
const originalHttpApi = Flag.KILO_EXPERIMENTAL_HTTPAPI
function app(input: { password?: string; username?: string }) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input.password,
KILO_SERVER_USERNAME: input.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function basic(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
async function cancelBody(response: Response) {
await response.body?.cancel().catch(() => {})
}
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = originalHttpApi
await disposeAllInstances()
await resetDatabase()
})
describe("HttpApi raw route authorization", () => {
test("requires configured auth before opening the raw instance event stream", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const headers = { "x-kilo-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 raw 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-kilo-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)
})
})
@@ -1,9 +1,11 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect } from "effect"
import { ConfigProvider, Effect, Layer } from "effect"
import type * as Scope from "effect/Scope"
import { HttpRouter } from "effect/unstable/http"
import { Flag } from "@opencode-ai/core/flag/flag"
import { createKiloClient } from "@kilocode/sdk/v2"
import { Instance } from "../../src/project/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { Server } from "../../src/server/server"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
@@ -13,7 +15,7 @@ import { Session as SessionNs } from "@/session/session"
import { TestLLMServer } from "../lib/llm-server"
import path from "path"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
const original = {
@@ -33,7 +35,27 @@ function app(backend: Backend, input?: { password?: string; username?: string })
Flag.KILO_EXPERIMENTAL_HTTPAPI = backend === "httpapi"
Flag.KILO_SERVER_PASSWORD = input?.password
Flag.KILO_SERVER_USERNAME = input?.username
return backend === "httpapi" ? Server.Default().app : Server.Legacy().app
if (backend === "legacy") return Server.Legacy().app
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input?.password,
KILO_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input: string | URL | Request, init?: RequestInit) {
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
},
}
}
function client(
@@ -123,7 +145,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator<unknown> }>) {
}
function record(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
}
function array(value: unknown) {
@@ -147,7 +169,7 @@ function sessionTitles(value: unknown) {
function resetState() {
return Effect.promise(async () => {
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
}
@@ -253,7 +275,7 @@ afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI
Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD
Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -402,7 +424,7 @@ describe("HttpApi SDK", () => {
lsp,
}),
project: { worktreeSelected: record(project.data).worktree === directory },
paths: { cwdSelected: record(paths.data).cwd === 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"),
@@ -1,26 +1,36 @@
import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { Server } from "../../src/server/server"
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
import { Session } from "@/session/session"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { it } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES
function app() {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
@@ -72,10 +82,38 @@ function createTextMessage(directory: string, sessionID: SessionID, text: string
)
}
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.gen(function* () {
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
return yield* Workspace.Service.use((svc) =>
svc.create({
type: input.type,
branch: null,
extra: null,
projectID: input.projectID,
}),
).pipe(Effect.provide(Workspace.defaultLayer))
})
function request(path: string, init?: RequestInit) {
return Effect.promise(async () => app().request(path, init))
}
function requestWithBackend(experimental: boolean, path: string, init?: RequestInit) {
return Effect.promise(async () => app(experimental).request(path, init))
}
function json<T>(response: Response) {
return Effect.promise(async () => {
if (response.status !== 200) throw new Error(await response.text())
@@ -99,7 +137,8 @@ function withTmp<A, E, R>(
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await disposeAllInstances()
await resetDatabase()
})
@@ -217,6 +256,129 @@ describe("session HttpApi", () => {
),
)
it.live(
"persists selected workspace id when creating a session",
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
Effect.gen(function* () {
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
const project = yield* Project.use.fromDirectory(tmp.path).pipe(Effect.provide(Project.defaultLayer))
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "session-create-workspace",
directory: path.join(tmp.path, ".workspace-local"),
})
const created = yield* requestJson<Session.Info>(`${SessionPaths.create}?workspace=${workspace.id}`, {
method: "POST",
headers: { "x-kilo-directory": tmp.path, "content-type": "application/json" },
body: JSON.stringify({ title: "workspace session" }),
})
expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id })
expect(
yield* Effect.sync(() =>
Database.use((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get(),
),
),
).toEqual({ workspaceID: workspace.id })
}),
),
)
it.live(
"matches legacy archived timestamp validation",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" }
const legacy = yield* createSession(tmp.path, { title: "legacy" })
const effect = yield* createSession(tmp.path, { title: "effect" })
const body = JSON.stringify({ time: { archived: -1 } })
const legacyResponse = yield* requestWithBackend(
false,
pathFor(SessionPaths.update, { sessionID: legacy.id }),
{
method: "PATCH",
headers,
body,
},
)
expect(legacyResponse.status).toBe(200)
expect((yield* json<Session.Info>(legacyResponse)).time.archived).toBe(-1)
const effectResponse = yield* requestWithBackend(true, pathFor(SessionPaths.update, { sessionID: effect.id }), {
method: "PATCH",
headers,
body,
})
expect(effectResponse.status).toBe(legacyResponse.status)
expect((yield* json<Session.Info>(effectResponse)).time.archived).toBe(-1)
}),
),
)
it.live(
"matches legacy project-scoped path and directory precedence",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const currentDir = path.join(tmp.path, "packages", "opencode", "src")
yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
const pathSession = yield* createSession(currentDir)
const pathlessSession = yield* createSession(currentDir)
yield* Effect.sync(() =>
Database.use((db) =>
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(),
),
)
const query = new URLSearchParams({
scope: "project",
path: "packages/opencode/src",
directory: currentDir,
})
const headers = { "x-kilo-directory": tmp.path }
const legacy = (yield* json<Session.Info[]>(
yield* requestWithBackend(false, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
const effect = (yield* json<Session.Info[]>(
yield* requestWithBackend(true, `${SessionPaths.list}?${query}`, { headers }),
)).map((item) => item.id)
expect(legacy).toContain(pathSession.id)
expect(legacy).not.toContain(pathlessSession.id)
expect(effect).toEqual(legacy)
}),
),
)
it.live(
"matches legacy paginated message link headers",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
Effect.gen(function* () {
const headers = { "x-kilo-directory": tmp.path }
const session = yield* createSession(tmp.path, { title: "messages" })
yield* createTextMessage(tmp.path, session.id, "first")
yield* createTextMessage(tmp.path, session.id, "second")
const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
const legacy = yield* requestWithBackend(false, route, { headers })
const effect = yield* requestWithBackend(true, route, { headers })
expect(effect.headers.get("x-next-cursor")).toBe(legacy.headers.get("x-next-cursor"))
expect(effect.headers.get("link")).toBe(legacy.headers.get("link"))
expect(effect.headers.get("access-control-expose-headers")).toBe(
legacy.headers.get("access-control-expose-headers"),
)
}),
),
)
it.live(
"serves message mutation routes through Hono bridge",
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
@@ -7,7 +7,7 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -24,9 +24,10 @@ function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
}
afterEach(async () => {
mock.restore()
Flag.KILO_EXPERIMENTAL_HTTPAPI = originalHttpApi
Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -35,6 +36,7 @@ describe("sync HttpApi", () => {
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" }
const info = spyOn(Log.create({ service: "server.sync" }), "info")
const session = await Instance.provide({
directory: tmp.path,
@@ -78,6 +80,8 @@ describe("sync HttpApi", () => {
})
expect(replayed.status).toBe(200)
expect(await replayed.json()).toEqual({ sessionID: session.id })
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
})
test("matches legacy seq validation", async () => {
@@ -1,6 +1,8 @@
import { afterEach, describe, expect, test } from "bun:test"
import type { Context } from "hono"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "../../src/bus/global"
import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { SessionID } from "../../src/session/schema"
import { Instance } from "../../src/project/instance"
import { TuiApi, TuiPaths } from "../../src/server/routes/instance/httpapi/groups/tui"
@@ -9,15 +11,26 @@ import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { OpenApi } from "effect/unstable/httpapi"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.KILO_EXPERIMENTAL_HTTPAPI
function app() {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
return Server.Default().app
function app(experimental = true) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
return experimental ? Server.Default().app : Server.Legacy().app
}
function nextCommandExecute() {
return new Promise<unknown>((resolve) => {
const listener = (event: { payload: { type?: string; properties?: { command?: unknown } } }) => {
if (event.payload.type !== TuiEvent.CommandExecute.type) return
GlobalBus.off("event", listener)
resolve(event.payload.properties?.command)
}
GlobalBus.on("event", listener)
})
}
async function expectTrue(path: string, headers: Record<string, string>, body?: unknown) {
@@ -32,7 +45,7 @@ async function expectTrue(path: string, headers: Record<string, string>, body?:
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
@@ -72,6 +85,27 @@ describe("tui HttpApi bridge", () => {
expect(missing.status).toBe(404)
})
test("matches legacy unknown execute command behavior", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-kilo-directory": tmp.path, "content-type": "application/json" }
const body = JSON.stringify({ command: "unknown_command" })
const legacyCommand = nextCommandExecute()
const legacy = await app(false).request(TuiPaths.executeCommand, { method: "POST", headers, body })
expect(legacy.status).toBe(200)
expect(await legacy.json()).toBe(true)
const effectCommand = nextCommandExecute()
const effect = await app().request(TuiPaths.executeCommand, { method: "POST", headers, body })
expect(effect.status).toBe(200)
expect(await effect.json()).toBe(true)
const legacyPublished = await legacyCommand
const effectPublished = await effectCommand
expect(effectPublished).toBe(legacyPublished)
expect(legacyPublished).toBeUndefined()
})
test("serves TUI control queue through experimental Effect routes", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const pending = callTui({ req: { json: async () => ({ value: 1 }), path: "/demo" } } as unknown as Context)
@@ -0,0 +1,245 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigProvider, Effect, Layer } from "effect"
import {
HttpClient,
HttpClientRequest,
HttpClientResponse,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import {
ServerAuthConfig,
authorizationRouterMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { serveUIEffect } from "../../src/server/routes/ui"
import { Server } from "../../src/server/server"
void Log.init({ print: false })
const original = {
KILO_EXPERIMENTAL_HTTPAPI: Flag.KILO_EXPERIMENTAL_HTTPAPI,
KILO_DISABLE_EMBEDDED_WEB_UI: Flag.KILO_DISABLE_EMBEDDED_WEB_UI,
KILO_SERVER_PASSWORD: Flag.KILO_SERVER_PASSWORD,
KILO_SERVER_USERNAME: Flag.KILO_SERVER_USERNAME,
envPassword: process.env.KILO_SERVER_PASSWORD,
envUsername: process.env.KILO_SERVER_USERNAME,
}
afterEach(() => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = original.KILO_EXPERIMENTAL_HTTPAPI
Flag.KILO_DISABLE_EMBEDDED_WEB_UI = original.KILO_DISABLE_EMBEDDED_WEB_UI
Flag.KILO_SERVER_PASSWORD = original.KILO_SERVER_PASSWORD
Flag.KILO_SERVER_USERNAME = original.KILO_SERVER_USERNAME
restoreEnv("KILO_SERVER_PASSWORD", original.envPassword)
restoreEnv("KILO_SERVER_USERNAME", original.envUsername)
})
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(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input?.password,
KILO_SERVER_USERNAME: input?.username,
}),
),
),
),
{ disableLogger: true },
).handler
return {
request(input: string | URL | Request, init?: RequestInit) {
return handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
ExperimentalHttpApiServer.context,
)
},
}
}
function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) {
const handler = HttpRouter.toWebHandler(
HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}),
).pipe(
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
Layer.provide([
AppFileSystem.defaultLayer,
input?.client ?? httpClient(new Response("ui")),
HttpServer.layerServices,
ConfigProvider.layer(
ConfigProvider.fromUnknown({
KILO_SERVER_PASSWORD: input?.password,
KILO_SERVER_USERNAME: input?.username,
}),
),
]),
),
{ disableLogger: true },
).handler
return {
request(input: string | URL | Request, init?: RequestInit) {
return handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
ExperimentalHttpApiServer.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))
}),
)
}
describe("HttpApi UI fallback", () => {
test("serves the web UI through the experimental backend", async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
const response = await uiApp({
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(await response.text()).toBe("<html>opencode</html>")
expect(proxiedUrl).toBe("https://app.opencode.ai/")
})
test("strips upstream transfer encoding headers from proxied assets", async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
const response = await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
fs,
client,
})
}).pipe(
Effect.provide(
Layer.mergeAll(
AppFileSystem.defaultLayer,
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(await response.text()).toBe("console.log('ok')")
})
test("keeps matched API routes ahead of the UI fallback", async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
const response = await Server.Default().app.request("/session/nope")
expect(response.status).toBe(404)
})
test("requires server password for the web UI", async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({ password: "secret", username: "opencode" }).request("/")
expect(response.status).toBe(401)
})
test("accepts auth token for the web UI", async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({
password: "secret",
username: "opencode",
client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
}).request(`/?auth_token=${btoa("opencode:secret")}`)
expect(response.status).toBe(200)
expect(await response.text()).toBe("<html>opencode</html>")
})
test("accepts basic auth for the web UI", async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
Flag.KILO_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({ password: "secret", username: "opencode" }).request("/", {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
expect(response.status).toBe(200)
})
test("allows web UI preflight without auth", async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
const response = await 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")
})
})
@@ -0,0 +1,471 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import Http from "node:http"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { registerAdapter } from "../../src/control-plane/adapters"
import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
WorkspaceRouteContext,
workspaceRouterMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { Database } from "../../src/storage/db"
import { resetDatabase } from "../fixture/db"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES
yield* Effect.promise(() => resetDatabase())
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await resetDatabase()
}),
)
}),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest,
NodeServices.layer,
Project.defaultLayer,
Workspace.defaultLayer,
Socket.layerWebSocketConstructorGlobal,
),
)
type ProxiedRequest = {
url: string
method: string
headers: Record<string, string>
}
type TestHandler<E, R> = (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
Layer.provide([Socket.layerWebSocketConstructorGlobal, 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.Service.use((workspace) => workspace.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.sync(() => {
const id = WorkspaceID.ascending()
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
return id
})
const startRemoteWorkspaceHttpServer = <E, R>(
handler: (request: ProxiedRequest) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>,
) =>
listenAdditionalServer((request) =>
Effect.gen(function* () {
// Remote workspaces run a sync loop against their target server. These
// bootstrap routes make Workspace.isSyncing(...) true for proxy tests;
// everything else is the request being proxied by the middleware.
const sync = syncResponse(request)
if (sync) return yield* sync
return yield* handler({ url: request.url, method: request.method, headers: request.headers })
}),
)
const listenRemoteWebSocket = () =>
listenAdditionalServer((request) => {
const sync = syncResponse(request)
if (sync) return sync
if (requestURL(request).pathname !== "/base/probe") return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return echoWebSocket(request)
})
const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
Effect.gen(function* () {
const socket = yield* Effect.orDie(request.upgrade)
const write = yield* socket.writer
yield* socket
.runRaw((message) => write(`echo:${String(message)}`), {
onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
Effect.catch(() => Effect.void),
),
})
.pipe(Effect.catch(() => Effect.void))
return HttpServerResponse.empty()
})
const serveRouteContextProbe = HttpRouter.add(
"GET",
"/probe",
Effect.gen(function* () {
// The fake route exposes the context installed by the middleware, so tests
// can assert routing decisions without pulling in the production API tree.
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
describe("HttpApi workspace routing middleware", () => {
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
let forwarded: ProxiedRequest | undefined
// This starts a second HTTP server that stands in for the opencode server
// backing a remote workspace. The client below still calls the local test
// server; only the middleware should call this server.
const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => {
forwarded = request
const url = requestURL(request)
return HttpServerResponse.json(
{
proxied: true,
path: url.pathname,
keep: url.searchParams.get("keep"),
workspace: url.searchParams.get("workspace"),
},
{ status: 201, headers: { "x-remote": "yes" } },
)
})
// The 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* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
HttpClientRequest.setHeaders({
"content-type": "application/json",
"x-kilo-directory": "/secret/path",
"x-kilo-workspace": "internal",
}),
HttpClient.execute,
)
expect(response.status).toBe(201)
expect(response.headers["x-remote"]).toBe("yes")
expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null })
const forwardedURL = forwarded ? requestURL(forwarded) : undefined
// These assertions are the routing contract: append the original path to
// the remote base URL, preserve normal query params, and remove workspace.
expect(forwardedURL?.pathname).toBe("/base/probe")
expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
expect(forwarded?.method).toBe("PATCH")
expect(forwarded?.headers["content-type"]).toBe("application/json")
expect(forwarded?.headers["x-target-auth"]).toBe("secret")
expect(forwarded?.headers["x-kilo-directory"]).toBeUndefined()
expect(forwarded?.headers["x-kilo-workspace"]).toBeUndefined()
}),
)
it.live("returns 503 when a remote workspace is not actively syncing", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceID = yield* insertRemoteWorkspaceWithoutSync({
dir,
projectID: project.project.id,
type: "remote-not-syncing",
url: "http://127.0.0.1:1/base",
})
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
expect(response.status).toBe(503)
expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`)
}),
)
it.live("proxies remote workspace WebSocket requests through the selected workspace target", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const remoteUrl = yield* listenRemoteWebSocket()
const workspace = yield* createRemoteWorkspace({
dir,
projectID: project.project.id,
type: "remote-websocket-target",
url: `${remoteUrl}/base`,
})
// The client connects to the local test server. The middleware should
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
{
closeCodeIsError: () => false,
protocols: "chat",
},
)
const messages = yield* Queue.unbounded<string>()
yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped)
const write = yield* socket.writer
expect(yield* Queue.take(messages)).toBe("protocol:chat")
yield* write("hello")
expect(yield* Queue.take(messages)).toBe("echo:hello")
}),
)
it.live("returns a missing workspace response for unknown workspace ids", () =>
Effect.gen(function* () {
const workspaceID = WorkspaceID.ascending("wrk_missing")
// If the middleware resolves the workspace first, this handler is never
// reached and the response should be the middleware error response.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
expect(response.status).toBe(500)
expect(yield* response.text).toBe(`Workspace not found: ${workspaceID}`)
}),
)
it.live("keeps control-plane routes local even when workspace is selected", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "control-plane-target",
directory: workspaceDir,
})
// GET /session is a control-plane route: it lists sessions for the main
// process and should not be redirected into the selected workspace target.
yield* HttpRouter.add(
"GET",
"/session",
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: workspace.id })
}),
)
it.live("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* HttpRouter.add(
"GET",
WorkspacePaths.list,
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
const response = yield* HttpClient.get(`${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* serveRouteContextProbe
// Without a selected workspace, the middleware falls back to request
// directory hints before using the process cwd.
const queryResponse = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(queryDir)}`)
const headerResponse = yield* HttpClientRequest.get("/probe").pipe(
HttpClientRequest.setHeader("x-kilo-directory", headerDir),
HttpClient.execute,
)
expect(queryResponse.status).toBe(200)
expect(yield* queryResponse.json).toEqual({ directory: queryDir })
expect(headerResponse.status).toBe(200)
expect(yield* headerResponse.json).toEqual({ directory: headerDir })
}),
)
it.live("routes local workspace requests through WorkspaceRouteContext", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
const workspaceDir = path.join(dir, ".workspace-local")
const workspace = yield* createLocalWorkspace({
projectID: project.project.id,
type: "local-target",
directory: workspaceDir,
})
yield* serveRouteContextProbe
// /probe is not a control-plane route, so selecting a local workspace
// should swap the route context to the workspace target directory.
const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({
directory: workspaceDir,
workspaceID: workspace.id,
})
}),
)
})
@@ -1,43 +1,42 @@
import { afterEach, describe, expect, mock, test } from "bun:test"
import { afterEach, describe, expect, mock } from "bun:test"
import { NodeServices } from "@effect/platform-node"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { registerAdapter } from "../../src/control-plane/adapters"
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 { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { WorkspaceRef } from "../../src/effect/instance-ref"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const originalWorkspaces = Flag.KILO_EXPERIMENTAL_WORKSPACES
const originalHttpApi = Flag.KILO_EXPERIMENTAL_HTTPAPI
const it = testEffect(
Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, Workspace.defaultLayer),
)
function request(path: string, directory: string, init: RequestInit = {}) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = true
const headers = new Headers(init.headers)
headers.set("x-kilo-directory", directory)
return Server.Default().app.request(path, { ...init, headers })
function request(path: string, directory: string, init: RequestInit = {}, httpApi = true) {
return Effect.promise(() => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = httpApi
const headers = new Headers(init.headers)
headers.set("x-kilo-directory", directory)
return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
})
}
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>, workspaceID?: Workspace.Info["id"]) {
return Effect.runPromise(
fx.pipe(
workspaceID ? Effect.provideService(WorkspaceRef, workspaceID) : (effect) => effect,
Effect.provide(Session.defaultLayer),
),
)
}
function localAdaptor(directory: string): WorkspaceAdaptor {
function localAdapter(directory: string): WorkspaceAdapter {
return {
name: "Local Test",
description: "Create a local test workspace",
@@ -61,7 +60,7 @@ function localAdaptor(directory: string): WorkspaceAdaptor {
}
}
function remoteAdaptor(directory: string, url: string, headers?: HeadersInit): WorkspaceAdaptor {
function remoteAdapter(directory: string, url: string, headers?: HeadersInit): WorkspaceAdapter {
return {
name: "Remote Test",
description: "Create a remote test workspace",
@@ -129,250 +128,300 @@ afterEach(async () => {
mock.restore()
Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces
Flag.KILO_EXPERIMENTAL_HTTPAPI = originalHttpApi
await Instance.disposeAll()
await disposeAllInstances()
await resetDatabase()
})
describe("workspace HttpApi", () => {
test.todo("proxies remote workspace websocket through real Effect listener", () => {})
it.live("serves read endpoints", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
test("serves read endpoints", async () => {
await using tmp = await tmpdir({ git: true })
const [adapters, workspaces, status] = yield* Effect.all([
request(WorkspacePaths.adapters, dir),
request(WorkspacePaths.list, dir),
request(WorkspacePaths.status, dir),
])
const [adaptors, workspaces, status] = await Promise.all([
request(WorkspacePaths.adaptors, tmp.path),
request(WorkspacePaths.list, tmp.path),
request(WorkspacePaths.status, tmp.path),
])
expect(adaptors.status).toBe(200)
expect(await adaptors.json()).toEqual([
{
expect(adapters.status).toBe(200)
expect(yield* Effect.promise(() => adapters.json())).toContainEqual({
type: "worktree",
name: "Worktree",
description: "Create a git worktree",
},
])
expect(workspaces.status).toBe(200)
expect(await workspaces.json()).toEqual([])
expect(status.status).toBe(200)
expect(await status.json()).toEqual([])
})
test("serves mutation endpoints", async () => {
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () =>
registerAdaptor(Instance.project.id, "local-test", localAdaptor(path.join(tmp.path, ".workspace"))),
})
const created = await request(WorkspacePaths.list, tmp.path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
})
expect(created.status).toBe(200)
const workspace = (await created.json()) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = await Instance.provide({
directory: tmp.path,
fn: async () => runSession(Session.Service.use((svc) => svc.create({}))),
})
const restored = await request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), tmp.path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(restored.status).toBe(200)
expect((await restored.json()) as { total: number }).toMatchObject({ total: expect.any(Number) })
const removed = await request(WorkspacePaths.remove.replace(":id", workspace.id), tmp.path, { method: "DELETE" })
expect(removed.status).toBe(200)
expect(await removed.json()).toMatchObject({ id: workspace.id })
const listed = await request(WorkspacePaths.list, tmp.path)
expect(listed.status).toBe(200)
expect(await listed.json()).toEqual([])
})
test("routes local workspace requests through the workspace target directory", async () => {
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ git: true })
const workspaceDir = path.join(tmp.path, ".workspace-local")
const workspace = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "local-target", localAdaptor(workspaceDir))
return Workspace.create({
type: "local-target",
branch: null,
extra: null,
projectID: Instance.project.id,
})
},
})
const url = new URL(`http://localhost${InstancePaths.path}`)
url.searchParams.set("workspace", workspace.id)
try {
const response = await request(url.toString(), tmp.path)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ directory: workspaceDir })
} finally {
await Workspace.remove(workspace.id)
}
})
test("proxies remote workspace HTTP requests with sanitized forwarding", async () => {
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ 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 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 workspace = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(
Instance.project.id,
"remote-target",
remoteAdaptor(path.join(tmp.path, ".remote"), `http://127.0.0.1:${remote.port}/base`, {
"x-target-auth": "secret",
}),
)
return Workspace.create({
type: "remote-target",
branch: null,
extra: null,
projectID: Instance.project.id,
})
},
})
const url = new URL("http://localhost/config")
url.searchParams.set("workspace", workspace.id)
url.searchParams.set("keep", "yes")
try {
const response = await request(url.toString(), tmp.path, {
method: "PATCH",
headers: {
"accept-encoding": "br",
"content-type": "application/json",
"x-kilo-workspace": "internal",
},
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
})
const responseBody = await response.text()
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
expect(response.headers.get("content-length")).toBeNull()
expect(response.headers.get("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-kilo-directory")
expect(forwarded[0]?.headers).not.toHaveProperty("x-kilo-workspace")
} finally {
remote.stop(true)
await Workspace.remove(workspace.id)
}
})
expect(workspaces.status).toBe(200)
expect(yield* Effect.promise(() => workspaces.json())).toEqual([])
test("proxies remote workspace requests selected from session ownership", async () => {
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
await using tmp = await tmpdir({ 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 })
})
expect(status.status).toBe(200)
expect(yield* Effect.promise(() => status.json())).toEqual([])
}),
)
const workspace = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(
Instance.project.id,
"remote-session-target",
remoteAdaptor(path.join(tmp.path, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
)
return Workspace.create({
type: "remote-session-target",
branch: null,
extra: null,
projectID: Instance.project.id,
})
},
})
const session = await Instance.provide({
directory: tmp.path,
fn: async () =>
runSession(
Session.Service.use((svc) => svc.create()),
workspace.id,
),
})
it.live("serves mutation endpoints", () =>
Effect.gen(function* () {
Flag.KILO_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")))
try {
const response = await request(`http://localhost/session/${session.id}/message`, tmp.path, {
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
body: JSON.stringify({ type: "local-test", branch: null, extra: null }),
})
expect(created.status).toBe(200)
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
const session = yield* Session.Service.use((svc) => svc.create({})).pipe(provideInstance(dir))
const restored = yield* request(WorkspacePaths.sessionRestore.replace(":id", workspace.id), dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionID: session.id }),
})
expect(restored.status).toBe(200)
expect((yield* Effect.promise(() => restored.json())) as { total: number }).toMatchObject({
total: expect.any(Number),
})
const responseBody = await 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`,
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
expect(removed.status).toBe(200)
expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id })
const listed = yield* request(WorkspacePaths.list, dir)
expect(listed.status).toBe(200)
expect(yield* Effect.promise(() => listed.json())).toEqual([])
}),
)
it.live("creates workspace with the TUI payload shape", () =>
Effect.gen(function* () {
Flag.KILO_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* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
extra: null,
})
}),
)
it.live("creates a real git worktree workspace via the builtin adapter", () =>
Effect.gen(function* () {
Flag.KILO_EXPERIMENTAL_WORKSPACES = true
const dir = yield* tmpdirScoped({ git: true })
const created = yield* request(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("documents legacy Hono accepting the TUI payload shape", () =>
Effect.gen(function* () {
Flag.KILO_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 }),
},
false,
)
expect(created.status).toBe(200)
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
type: "local-test",
name: "local-test",
extra: null,
})
}),
)
it.live("routes local workspace requests through the workspace target directory", () =>
Effect.gen(function* () {
Flag.KILO_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, extra: null }),
})
const workspace = (yield* Effect.promise(() => 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* Effect.promise(() => 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.KILO_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 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",
}),
])
} finally {
remote.stop(true)
await Workspace.remove(workspace.id)
}
})
)
const created = yield* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-target", branch: null, extra: null }),
})
const workspace = (yield* Effect.promise(() => 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* request(url.toString(), dir, {
method: "PATCH",
headers: {
"accept-encoding": "br",
"content-type": "application/json",
"x-kilo-workspace": "internal",
},
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
})
const responseBody = yield* Effect.promise(() => response.text())
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
expect(response.headers.get("content-length")).toBeNull()
expect(response.headers.get("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-kilo-directory")
expect(forwarded[0]?.headers).not.toHaveProperty("x-kilo-workspace")
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}
}),
)
it.live("proxies remote workspace requests selected from session ownership", () =>
Effect.gen(function* () {
Flag.KILO_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* request(WorkspacePaths.list, dir, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "remote-session-target", branch: null, extra: null }),
})
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
const session = yield* Session.Service.use((svc) => svc.create()).pipe(
Effect.provideService(WorkspaceRef, workspace.id),
provideInstance(dir),
)
try {
const response = yield* request(`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* Effect.promise(() => 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",
}),
])
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
}
}),
)
})
@@ -1,14 +1,13 @@
import { afterEach, describe, expect, spyOn, test } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import path from "path"
import { GlobalBus } from "../../src/bus/global"
import { Snapshot } from "../../src/snapshot"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { provideInstance, tmpdir } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -16,6 +15,9 @@ afterEach(async () => {
await resetDatabase()
})
const disposedEvents = (seen: { directory?: string; payload: { type: string } }[], dir: string) =>
seen.filter((evt) => evt.directory === dir && evt.payload.type === "server.instance.disposed").length
describe("project.initGit endpoint", () => {
test("initializes git and reloads immediately", async () => {
await using tmp = await tmpdir()
@@ -24,8 +26,6 @@ describe("project.initGit endpoint", () => {
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
}
const reload = Instance.reload
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
GlobalBus.on("event", fn)
try {
@@ -42,10 +42,8 @@ describe("project.initGit endpoint", () => {
vcs: "git",
worktree: tmp.path,
})
expect(reloadSpy).toHaveBeenCalledTimes(1)
expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe(
true,
)
// Reload behavior: bus emits exactly one server.instance.disposed for the directory.
expect(disposedEvents(seen, tmp.path)).toBe(1)
expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false)
const current = await app.request("/project/current", {
@@ -69,8 +67,7 @@ describe("project.initGit endpoint", () => {
),
).toBeTruthy()
} finally {
await Instance.disposeAll()
reloadSpy.mockRestore()
await disposeAllInstances()
GlobalBus.off("event", fn)
}
})
@@ -82,8 +79,6 @@ describe("project.initGit endpoint", () => {
const fn = (evt: { directory?: string; payload: { type: string } }) => {
seen.push(evt)
}
const reload = Instance.reload
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
GlobalBus.on("event", fn)
try {
@@ -98,10 +93,7 @@ describe("project.initGit endpoint", () => {
vcs: "git",
worktree: tmp.path,
})
expect(
seen.filter((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed").length,
).toBe(0)
expect(reloadSpy).toHaveBeenCalledTimes(0)
expect(disposedEvents(seen, tmp.path)).toBe(0)
const current = await app.request("/project/current", {
headers: {
@@ -114,8 +106,7 @@ describe("project.initGit endpoint", () => {
worktree: tmp.path,
})
} finally {
await Instance.disposeAll()
reloadSpy.mockRestore()
await disposeAllInstances()
GlobalBus.off("event", fn)
}
})
@@ -5,7 +5,7 @@ import { Server } from "../../src/server/server"
import { Session as SessionNs } from "@/session/session"
import type { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -25,7 +25,7 @@ const svc = {
afterEach(async () => {
mock.restore()
await Instance.disposeAll()
await disposeAllInstances()
})
describe("session action routes", () => {
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { Instance } from "../../src/project/instance"
import { Session as SessionNs } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Flag } from "@opencode-ai/core/flag/flag"
import { mkdir } from "fs/promises"
import path from "path"
@@ -23,11 +23,14 @@ const svc = {
create(input?: SessionNs.CreateInput) {
return run(SessionNs.Service.use((svc) => svc.create(input)))
},
list(input?: SessionNs.ListInput) {
return run(SessionNs.Service.use((svc) => svc.list(input)))
},
}
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_WORKSPACES = originalWorkspaces
await Instance.disposeAll()
await disposeAllInstances()
})
describe("session.list", () => {
@@ -55,7 +58,7 @@ describe("session.list", () => {
fn: async () => svc.create({ title: "sibling" }),
})
const ids = [...svc.list()].map((s) => s.id)
const ids = (await svc.list()).map((s) => s.id)
expect(ids).toContain(root.id)
expect(ids).toContain(parent.id)
expect(ids).toContain(current.id)
@@ -88,7 +91,7 @@ describe("session.list", () => {
fn: async () => svc.create({ title: "sibling" }),
})
const ids = [...svc.list({ directory: path.join(tmp.path, "packages", "opencode") })].map((s) => s.id)
const ids = (await svc.list({ directory: path.join(tmp.path, "packages", "opencode") })).map((s) => s.id)
expect(ids).not.toContain(root.id)
expect(ids).not.toContain(parent.id)
expect(ids).toContain(current.id)
@@ -123,9 +126,12 @@ describe("session.list", () => {
fn: async () => svc.create({ title: "sibling" }),
})
const pathIDs = [
...svc.list({ directory: path.join(tmp.path, "packages", "app"), path: "packages/opencode/src" }),
].map((s) => s.id)
const pathIDs = (
await svc.list({
directory: path.join(tmp.path, "packages", "app"),
path: "packages/opencode/src",
})
).map((s) => s.id)
expect(pathIDs).not.toContain(parent.id)
expect(pathIDs).toContain(current.id)
expect(pathIDs).toContain(deeper.id)
@@ -155,9 +161,12 @@ describe("session.list", () => {
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run())
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run())
const pathIDs = [
...svc.list({ directory: path.join(tmp.path, "packages", "opencode", "src"), path: "packages/opencode/src" }),
].map((s) => s.id)
const pathIDs = (
await svc.list({
directory: path.join(tmp.path, "packages", "opencode", "src"),
path: "packages/opencode/src",
})
).map((s) => s.id)
expect(pathIDs).toContain(current.id)
expect(pathIDs).not.toContain(sibling.id)
},
@@ -172,7 +181,7 @@ describe("session.list", () => {
const root = await svc.create({ title: "root-session" })
const child = await svc.create({ title: "child-session", parentID: root.id })
const sessions = [...svc.list({ roots: true })]
const sessions = await svc.list({ roots: true })
const ids = sessions.map((s) => s.id)
expect(ids).toContain(root.id)
@@ -189,7 +198,7 @@ describe("session.list", () => {
await svc.create({ title: "new-session" })
const futureStart = Date.now() + 86400000
const sessions = [...svc.list({ start: futureStart })]
const sessions = await svc.list({ start: futureStart })
expect(sessions.length).toBe(0)
},
})
@@ -203,7 +212,7 @@ describe("session.list", () => {
await svc.create({ title: "unique-search-term-abc" })
await svc.create({ title: "other-session-xyz" })
const sessions = [...svc.list({ search: "unique-search" })]
const sessions = await svc.list({ search: "unique-search" })
const titles = sessions.map((s) => s.title)
expect(titles).toContain("unique-search-term-abc")
@@ -221,7 +230,7 @@ describe("session.list", () => {
await svc.create({ title: "session-2" })
await svc.create({ title: "session-3" })
const sessions = [...svc.list({ limit: 2 })]
const sessions = await svc.list({ limit: 2 })
expect(sessions.length).toBe(2)
},
})
@@ -6,7 +6,7 @@ import { Session as SessionNs } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -31,7 +31,7 @@ const svc = {
}
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
async function withoutWatcher<T>(fn: () => Promise<T>) {
@@ -5,7 +5,7 @@ import type { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { tmpdir } from "../fixture/fixture"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
@@ -24,7 +24,7 @@ const svc = {
}
afterEach(async () => {
await Instance.disposeAll()
await disposeAllInstances()
})
describe("tui.selectSession endpoint", () => {
@@ -1,26 +1,68 @@
import { NodeHttpServer } from "@effect/platform-node"
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import Http from "node:http"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
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* () {
return HttpServer.formatAddress((yield* HttpServer.HttpServer).address)
yield* HttpServer.serveEffect()(HttpServerRequest.HttpServerRequest.use(handler))
return yield* serverUrl()
})
}
const testServerLayer = NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 })
const it = testEffect(testServerLayer)
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* () {
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
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 },
@@ -35,12 +77,17 @@ describe("HttpApi workspace proxy", () => {
)
}),
)
const url = yield* serverUrl()
const request = HttpServerRequest.fromWeb(
new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }),
)
const response = yield* HttpApiProxy.http(`${url}/session/abc?keep=yes`, { "x-extra": "injected" }, request)
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)
@@ -58,7 +105,8 @@ describe("HttpApi workspace proxy", () => {
it.live("returns 500 when remote is unreachable", () =>
Effect.gen(function* () {
const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything"))
const response = yield* HttpApiProxy.http("http://127.0.0.1:1/unreachable", undefined, request)
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)
}),
@@ -67,14 +115,12 @@ describe("HttpApi workspace proxy", () => {
it.live("strips opencode-internal headers and merges extra headers", () =>
Effect.gen(function* () {
let forwarded: Record<string, string> = {}
yield* HttpServer.serveEffect()(
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const url = yield* listenServer((req) =>
Effect.sync(() => {
forwarded = req.headers
return HttpServerResponse.empty()
}),
)
const url = yield* serverUrl()
const request = HttpServerRequest.fromWeb(
new Request("http://localhost/test", {
@@ -85,7 +131,8 @@ describe("HttpApi workspace proxy", () => {
},
}),
)
yield* HttpApiProxy.http(`${url}/test`, { "x-injected": "extra" }, request)
const httpClient = yield* HttpClient.HttpClient
yield* HttpApiProxy.http(httpClient, `${url}/test`, { "x-injected": "extra" }, request)
expect(forwarded["x-kilo-directory"]).toBeUndefined()
expect(forwarded["x-kilo-workspace"]).toBeUndefined()
@@ -93,4 +140,26 @@ describe("HttpApi workspace proxy", () => {
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")
}),
)
})