mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
resolve merge conflicts
This commit is contained in:
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user