fix(server): support desktop PTY websockets with HttpApi (#25598)
This commit is contained in:
@@ -2,7 +2,7 @@ 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 { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -13,11 +13,19 @@ const Api = HttpApi.make("test-authorization").add(
|
||||
HttpApiEndpoint.get("probe", "/probe", {
|
||||
success: Schema.String,
|
||||
}),
|
||||
HttpApiEndpoint.get("missing", "/missing", {
|
||||
success: Schema.String,
|
||||
error: HttpApiError.NotFound,
|
||||
}),
|
||||
)
|
||||
.middleware(Authorization),
|
||||
)
|
||||
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
|
||||
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
|
||||
handlers
|
||||
.handle("probe", () => Effect.succeed("ok"))
|
||||
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||
)
|
||||
|
||||
const apiLayer = HttpRouter.serve(
|
||||
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
|
||||
@@ -32,8 +40,7 @@ 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 basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
|
||||
|
||||
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
|
||||
|
||||
@@ -90,6 +97,35 @@ describe("HttpApi authorization middleware", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("prefers auth token query credentials over basic auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get(
|
||||
`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`,
|
||||
).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("preserves handler errors when basic auth succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClientRequest.get("/missing").pipe(
|
||||
HttpClientRequest.setHeader("authorization", basic("opencode", "secret")),
|
||||
HttpClient.execute,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("preserves handler errors when auth token query succeeds", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
)
|
||||
|
||||
itSecret.live("rejects malformed auth token query credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* HttpClient.get("/probe?auth_token=not-base64")
|
||||
|
||||
155
packages/opencode/test/server/httpapi-listen.test.ts
Normal file
155
packages/opencode/test/server/httpapi-listen.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
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 { Server } from "../../src/server/server"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { withTimeout } from "../../src/util/timeout"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||
envUsername: process.env.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
const auth = { username: "opencode", password: "listen-secret" }
|
||||
const testPty = process.platform === "win32" ? test.skip : test
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword
|
||||
if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME
|
||||
else process.env.OPENCODE_SERVER_USERNAME = original.envUsername
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
async function startListener() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = auth.username
|
||||
process.env.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
process.env.OPENCODE_SERVER_USERNAME = auth.username
|
||||
return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
function authorization() {
|
||||
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
||||
}
|
||||
|
||||
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
|
||||
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
|
||||
url.protocol = "ws:"
|
||||
url.searchParams.set("directory", dir)
|
||||
url.searchParams.set("cursor", "-1")
|
||||
url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`))
|
||||
return url
|
||||
}
|
||||
|
||||
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
|
||||
const response = await fetch(new URL(PtyPaths.create, listener.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: authorization(),
|
||||
"x-opencode-directory": dir,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
return (await response.json()) as { id: string }
|
||||
}
|
||||
|
||||
async function openSocket(url: URL) {
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = "arraybuffer"
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener("open", () => resolve(), { once: true })
|
||||
ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true })
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket open",
|
||||
)
|
||||
return ws
|
||||
}
|
||||
|
||||
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
|
||||
return withTimeout(listener.stop(true), 10_000, label)
|
||||
}
|
||||
|
||||
function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) {
|
||||
const decoder = new TextDecoder()
|
||||
let onMessage: ((event: MessageEvent) => void) | undefined
|
||||
return withTimeout(
|
||||
new Promise<string>((resolve) => {
|
||||
onMessage = (event: MessageEvent) => {
|
||||
const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer)
|
||||
if (!predicate(message)) return
|
||||
resolve(message)
|
||||
}
|
||||
ws.addEventListener("message", onMessage)
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket message",
|
||||
).finally(() => {
|
||||
if (onMessage) ws.removeEventListener("message", onMessage)
|
||||
})
|
||||
}
|
||||
|
||||
describe("HttpApi Server.listen", () => {
|
||||
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
let stopped = false
|
||||
try {
|
||||
const response = await fetch(new URL(PtyPaths.shells, listener.url), {
|
||||
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: expect.any(String),
|
||||
name: expect.any(String),
|
||||
acceptable: expect.any(Boolean),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
const info = await createCat(listener, tmp.path)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
|
||||
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
|
||||
|
||||
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
|
||||
ws.send("ping-listen\n")
|
||||
expect(await message).toContain("ping-listen")
|
||||
|
||||
await stop(listener, "timed out waiting for listener.stop(true)")
|
||||
stopped = true
|
||||
await withTimeout(closed, 5_000, "timed out waiting for websocket close")
|
||||
expect(ws.readyState).toBe(WebSocket.CLOSED)
|
||||
|
||||
const restarted = await startListener()
|
||||
try {
|
||||
const nextInfo = await createCat(restarted, tmp.path)
|
||||
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path))
|
||||
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
|
||||
nextWs.send("ping-restarted\n")
|
||||
expect(await nextMessage).toContain("ping-restarted")
|
||||
nextWs.close(1000)
|
||||
} finally {
|
||||
await stop(restarted, "timed out waiting for restarted listener.stop(true)")
|
||||
}
|
||||
} finally {
|
||||
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
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 { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { HttpApiListener } from "../../src/server/httpapi-listener"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const testPty = process.platform === "win32" ? test.skip : test
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
async function startListener() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
describe("native HttpApi listener", () => {
|
||||
test("serves HTTP routes via the HttpApi web handler", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
try {
|
||||
const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body[0]).toMatchObject({
|
||||
path: expect.any(String),
|
||||
name: expect.any(String),
|
||||
acceptable: expect.any(Boolean),
|
||||
})
|
||||
} finally {
|
||||
await listener.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("PTY websocket connect echoes input back to the client", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
try {
|
||||
const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-opencode-directory": tmp.path,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const info = (await created.json()) as { id: string }
|
||||
|
||||
try {
|
||||
const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url)
|
||||
wsURL.protocol = "ws:"
|
||||
wsURL.searchParams.set("directory", tmp.path)
|
||||
wsURL.searchParams.set("cursor", "-1")
|
||||
|
||||
const messages: string[] = []
|
||||
const ws = new WebSocket(wsURL)
|
||||
ws.binaryType = "arraybuffer"
|
||||
|
||||
const opened = new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener("open", () => resolve(), { once: true })
|
||||
ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true })
|
||||
})
|
||||
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
ws.addEventListener("close", () => resolve(), { once: true })
|
||||
})
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
const data = event.data
|
||||
messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer))
|
||||
})
|
||||
|
||||
await opened
|
||||
ws.send("ping-listener\n")
|
||||
|
||||
const start = Date.now()
|
||||
while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) {
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
}
|
||||
ws.close(1000, "done")
|
||||
|
||||
expect(messages.some((m) => m.includes("ping-listener"))).toBe(true)
|
||||
// Verify close event fires (handler.onClose path runs and the
|
||||
// Bun.serve websocket lifecycle reaches close).
|
||||
await closed
|
||||
expect(ws.readyState).toBe(WebSocket.CLOSED)
|
||||
} finally {
|
||||
await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, {
|
||||
method: "DELETE",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
} finally {
|
||||
await listener.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
|
||||
|
||||
const passthroughAuthorization = Layer.succeed(
|
||||
Authorization,
|
||||
Authorization.of({
|
||||
basic: (effect) => effect,
|
||||
authToken: (effect) => effect,
|
||||
}),
|
||||
Authorization.of((effect) => effect),
|
||||
)
|
||||
|
||||
const passthroughInstanceContext = Layer.succeed(
|
||||
|
||||
Reference in New Issue
Block a user