diff --git a/bun.lock b/bun.lock index ff4850147c..2de447620b 100644 --- a/bun.lock +++ b/bun.lock @@ -731,6 +731,7 @@ "effect": "catalog:", }, "devDependencies": { + "@effect/platform-node": "catalog:", "@opencode/ai": "workspace:*", "@opencode/httpapi-codegen": "workspace:*", "@opencode/protocol": "workspace:*", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0718e9c567..55ef1b4f62 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -37,6 +37,7 @@ "effect": "catalog:" }, "devDependencies": { + "@effect/platform-node": "catalog:", "@opencode/ai": "workspace:*", "@opencode/httpapi-codegen": "workspace:*", "@opencode/protocol": "workspace:*", diff --git a/packages/sdk/src/effect/opencode.ts b/packages/sdk/src/effect/opencode.ts index 80eac919b5..21c1b11e60 100644 --- a/packages/sdk/src/effect/opencode.ts +++ b/packages/sdk/src/effect/opencode.ts @@ -31,7 +31,10 @@ export type Interface = Omit & { readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType } readonly plugin: EmbeddedHost.Interface["plugins"]["register"] & OpenCodeClient["plugin"] - /** This instance's HTTP API for external opencode clients, to serve with an Effect `HttpServer`. */ + /** + * This instance's HTTP API for external opencode clients. Serve it with an Effect `HttpServer`; a fetch + * handler cannot accept PTY WebSockets. Stop that server before closing this instance. + */ readonly http: EmbeddedHost.Interface["http"] } diff --git a/packages/sdk/src/internal/host.ts b/packages/sdk/src/internal/host.ts index 7c064c69b3..009fd87860 100644 --- a/packages/sdk/src/internal/host.ts +++ b/packages/sdk/src/internal/host.ts @@ -5,11 +5,12 @@ import { SessionRestart } from "@opencode/core/session/execution/restart" import { Session } from "@opencode/core/session" import { Workspace } from "@opencode/core/workspace" import { WorkspaceDriver } from "@opencode/core/workspace/driver" +import { isAllowedCorsOrigin } from "@opencode/server/cors" import { createEmbeddedRoutes } from "@opencode/server/routes" import type { ServerOptions } from "@opencode/server/options" import type { LayerNode } from "@opencode/util/effect/layer-node" import { Context, Effect, Layer, ManagedRuntime, Scope } from "effect" -import { HttpEffect, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" +import { HttpEffect, HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http" import { context, layer, type LogOptions } from "../logging" import { OwnedFetch } from "./fetch" import { SdkInstances } from "./instances" @@ -22,6 +23,8 @@ export interface CreateOptions extends Omit> readonly instances?: SdkInstances.Options + /** Addresses where the embedder serves `http`, reported by `/api/info` and used for pairing links. */ + readonly urls?: ReadonlyArray } /** Host hooks for embedding opencode on a non-default runtime profile. */ @@ -33,7 +36,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* ( options: CreateOptions = {}, embed: EmbedOptions = {}, ) { - const { log, workspaceProviders, instances, ...server } = options + const { log, workspaceProviders, instances, urls, ...server } = options const selector = instances ? SdkInstances.provide(instances, yield* Effect.context()) : undefined const runtime = ManagedRuntime.make( createEmbeddedRoutes( @@ -46,6 +49,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* ( ? [...(embed.overrides ?? []), WorkspaceDriver.node.replace(WorkspaceDriver.registryNode(workspaceProviders))] : embed.overrides, selector ? (replacements) => SdkInstances.node(selector, replacements) : undefined, + () => urls ?? [], ).pipe(Layer.provide(HttpServer.layerServices), Layer.provideMerge(layer(log))), ) @@ -72,8 +76,10 @@ export const create = Effect.fn("EmbeddedHost.create")(function* ( return { runtime, - // Serve with an Effect HttpServer to expose this instance; a fetch handler cannot accept PTY WebSockets. - http: http.pipe(Effect.provideContext(context(services))), + http: http.pipe( + HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, server), maxAge: 86_400 }), + Effect.provideContext(context(services)), + ), fetch: transport.fetch, plugins: Context.get(services, SdkPlugins.Service), sessions: Context.get(services, Session.Service), diff --git a/packages/sdk/src/promise.ts b/packages/sdk/src/promise.ts index 61d3a31793..9eb3c10f02 100644 --- a/packages/sdk/src/promise.ts +++ b/packages/sdk/src/promise.ts @@ -17,7 +17,8 @@ export interface InstanceOptions { readonly configure: (key: string) => InstanceConfiguration | Promise } -export interface CreateOptions extends Omit { +export interface CreateOptions + extends Omit { readonly plugins?: ReadonlyArray readonly instances?: InstanceOptions } diff --git a/packages/sdk/test/http.test.ts b/packages/sdk/test/http.test.ts index 1ebff1f7c8..0267da27e4 100644 --- a/packages/sdk/test/http.test.ts +++ b/packages/sdk/test/http.test.ts @@ -1,12 +1,21 @@ +import { createServer } from "node:http" import { expect } from "bun:test" +import { NodeHttpServer } from "@effect/platform-node" import { Effect, Layer } from "effect" -import { HttpEffect } from "effect/unstable/http" +import { HttpServer } from "effect/unstable/http" import { testEffect } from "../../core/test/lib/effect" import { tmpdir } from "../../core/test/fixture/tmpdir" +import type { OpenCode } from "../src/effect" const it = testEffect(Layer.empty) +const ptyTest = process.platform === "win32" ? it.live.skip : it.live +const auth = { authorization: `Basic ${btoa("opencode:secret")}` } -it.live("serves the embedded instance to external clients", () => +// Serves the embedded instance as an embedder would: the server is acquired after the instance, so it stops first. +type Sdk = typeof import("../src/effect") +type Served = { sdk: Sdk; opencode: OpenCode.Interface; base: string; location: string; directory: string } + +const served = (f: (input: Served) => Effect.Effect) => Effect.acquireRelease( Effect.promise(() => tmpdir("opencode-sdk-http-")), (directory) => Effect.promise(() => directory[Symbol.asyncDispose]()), @@ -19,49 +28,104 @@ it.live("serves the embedded instance to external clients", () => models: { fetch: false }, fs: { filewatcher: false }, password: "secret", + urls: ["https://opencode.example"], }) - const handler = HttpEffect.toWebHandler(opencode.http) - const server = yield* Effect.acquireRelease( - Effect.sync(() => Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (request) => handler(request) })), - (server) => Effect.promise(() => server.stop(true)), - ) - const base = `http://127.0.0.1:${server.port}` - const auth = { authorization: `Basic ${btoa("opencode:secret")}` } - const location = `directory=${encodeURIComponent(directory.path)}` - - // In-process SDK calls still work once the routes require the password. - const created = yield* opencode.sessions.create({ - id: sdk.Session.ID.create(), - location: sdk.Location.Ref.make({ directory: sdk.AbsolutePath.make(directory.path) }), - }) - const page = yield* opencode.sessions.list({ directory: sdk.AbsolutePath.make(directory.path) }) - expect(page.data.map((session) => session.id)).toContain(created.id) - - yield* Effect.promise(async () => { - expect((await fetch(`${base}/api/session?${location}`)).status).toBe(401) - const listed = await fetch(`${base}/api/session?${location}`, { headers: auth }) - expect(listed.status).toBe(200) - expect(JSON.stringify(await listed.json())).toContain(created.id) - - // Server-sent events stream over the socket. - const events = await fetch(`${base}/api/event`, { headers: auth }) - expect(events.headers.get("content-type")).toContain("text/event-stream") - const reader = events.body!.getReader() - expect(new TextDecoder().decode((await reader.read()).value)).toContain("server.connected") - await reader.cancel() - - // A pairing code yields a session token that works as the password. - const pairing = await (await fetch(`${base}/api/pair`, { method: "POST", headers: auth })).json() - const session = await ( - await fetch(`${base}/auth/connect/${pairing.code}`, { headers: { accept: "application/json" } }) - ).json() - const paired = await fetch(`${base}/api/session?${location}`, { - headers: { authorization: `Basic ${btoa(`opencode:${session.token}`)}` }, - }) - expect(paired.status).toBe(200) + const node = createServer() + const server = yield* NodeHttpServer.make(() => node, { host: "127.0.0.1", port: 0 }) + yield* server.serve(opencode.http).pipe(Effect.provide(NodeHttpServer.layerHttpServices)) + // Close open SSE streams before the serving fiber stops. + yield* Effect.addFinalizer(() => Effect.sync(() => node.closeAllConnections())) + return yield* f({ + sdk, + opencode, + base: HttpServer.formatAddress(server.address), + location: `directory=${encodeURIComponent(directory.path)}`, + directory: directory.path, }) }), ), Effect.scoped, + ) + +it.live("serves the embedded instance to external clients", () => + served(({ sdk, opencode, base, location, directory }) => + Effect.gen(function* () { + // In-process SDK calls still work once the routes require the password. + const created = yield* opencode.sessions.create({ + id: sdk.Session.ID.create(), + location: sdk.Location.Ref.make({ directory: sdk.AbsolutePath.make(directory) }), + }) + const page = yield* opencode.sessions.list({ directory: sdk.AbsolutePath.make(directory) }) + expect(page.data.map((session) => session.id)).toContain(created.id) + yield* Effect.promise(async () => { + expect((await fetch(`${base}/api/session?${location}`)).status).toBe(401) + expect((await fetch(`${base}/api/session?${location}`, { headers: auth })).status).toBe(200) + expect((await (await fetch(`${base}/api/info`, { headers: auth })).json()).urls).toEqual([ + "https://opencode.example", + ]) + + const preflight = await fetch(`${base}/api/session`, { + method: "OPTIONS", + headers: { origin: "https://app.opencode.ai", "access-control-request-method": "GET" }, + }) + expect(preflight.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai") + + const events = await fetch(`${base}/api/event`, { headers: auth }) + expect(events.headers.get("content-type")).toContain("text/event-stream") + const reader = events.body!.getReader() + expect(new TextDecoder().decode((await reader.read()).value)).toContain("server.connected") + await reader.cancel() + + // A pairing code yields a session token that works as the password. + const pairing = await (await fetch(`${base}/api/pair`, { method: "POST", headers: auth })).json() + const session = await ( + await fetch(`${base}/auth/connect/${pairing.code}`, { headers: { accept: "application/json" } }) + ).json() + const paired = await fetch(`${base}/api/session?${location}`, { + headers: { authorization: `Basic ${btoa(`opencode:${session.token}`)}` }, + }) + expect(paired.status).toBe(200) + }) + }), + ), +) + +ptyTest("accepts PTY WebSockets", () => + served(({ base, location }) => + Effect.promise(async () => { + const pty = await ( + await fetch(`${base}/api/pty?${location}`, { + method: "POST", + headers: { ...auth, "content-type": "application/json" }, + body: JSON.stringify({ command: "/bin/sh", args: ["-c", "echo http-pty-ready; exec cat"] }), + }) + ).json() + const ticket = await ( + await fetch(`${base}/api/pty/${pty.data.id}/connect-token?${location}`, { + method: "POST", + headers: { ...auth, "x-opencode-ticket": "1" }, + }) + ).json() + const url = new URL(`/api/pty/${pty.data.id}/connect?${location}`, base) + url.protocol = "ws:" + url.searchParams.set("ticket", ticket.data.ticket) + const output = await new Promise((resolve, reject) => { + const socket = new WebSocket(url) + let text = "" + const timeout = setTimeout(() => reject(new Error(`PTY output missing: ${text}`)), 5_000) + socket.addEventListener("message", (event) => { + text += typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data) + if (!text.includes("http-pty-ready")) return + clearTimeout(timeout) + socket.close() + resolve(text) + }) + socket.addEventListener("error", () => { + clearTimeout(timeout) + reject(new Error("PTY WebSocket failed")) + }) + }) + expect(output).toContain("http-pty-ready") + }), ), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index a63fa35fa8..bf5330d544 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -100,11 +100,12 @@ export function createEmbeddedRoutes( options: ServerOptions = {}, overrides: LayerNode.Replacements = [], instances?: InstanceNode, + serviceURLs: () => ReadonlyArray = () => [], ) { return makeRoutes( ServerAuth.Config.configLayer({ password: Option.fromNullishOr(options.password) }), options, - () => [], + serviceURLs, overrides, instances, ) diff --git a/packages/server/test/embedded-http.test.ts b/packages/server/test/embedded-http.test.ts deleted file mode 100644 index 0b83b14882..0000000000 --- a/packages/server/test/embedded-http.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createServer } from "node:http" -import { expect } from "bun:test" -import { NodeHttpServer } from "@effect/platform-node" -import { Context, Effect, Layer } from "effect" -import { HttpRouter, HttpServer } from "effect/unstable/http" -import { tmpdirScoped } from "../../core/test/fixture/tmpdir" -import { it } from "../../core/test/lib/effect" -import { createEmbeddedRoutes } from "../src/routes" - -it.live("embedded routes accept password-authorized PTY WebSockets on an HTTP server", () => - Effect.gen(function* () { - const directory = yield* tmpdirScoped() - const context = yield* Layer.build( - createEmbeddedRoutes({ - password: "secret", - database: { path: ":memory:" }, - models: { fetch: false }, - config: { directory: directory.path, project: false, content: "{}" }, - fs: { filewatcher: false }, - }).pipe(Layer.provide(HttpServer.layerServices)), - ) - const server = yield* NodeHttpServer.make(() => createServer(), { port: 0, host: "127.0.0.1" }) - yield* server - .serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()) - .pipe(Effect.provide(NodeHttpServer.layerHttpServices)) - const base = HttpServer.formatAddress(server.address) - const auth = { authorization: `Basic ${btoa("opencode:secret")}` } - const location = `directory=${encodeURIComponent(directory.path)}` - - const output = yield* Effect.promise(async () => { - expect((await fetch(`${base}/api/pty?${location}`)).status).toBe(401) - const pty = await ( - await fetch(`${base}/api/pty?${location}`, { - method: "POST", - headers: { ...auth, "content-type": "application/json" }, - body: JSON.stringify({ command: "/bin/sh", args: ["-c", "echo embedded-pty-ready; exec cat"] }), - }) - ).json() - const ticket = await ( - await fetch(`${base}/api/pty/${pty.data.id}/connect-token?${location}`, { - method: "POST", - headers: { ...auth, "x-opencode-ticket": "1" }, - }) - ).json() - const url = new URL(`/api/pty/${pty.data.id}/connect?${location}`, base) - url.protocol = "ws:" - url.searchParams.set("ticket", ticket.data.ticket) - return new Promise((resolve, reject) => { - const socket = new WebSocket(url) - let text = "" - const timeout = setTimeout(() => reject(new Error(`PTY output missing: ${text}`)), 5_000) - socket.addEventListener("message", (event) => { - text += typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data) - if (!text.includes("embedded-pty-ready")) return - clearTimeout(timeout) - socket.close() - resolve(text) - }) - socket.addEventListener("error", () => { - clearTimeout(timeout) - reject(new Error("PTY WebSocket failed")) - }) - }) - }) - expect(output).toContain("embedded-pty-ready") - }), -)