mirror of
https://github.com/Heretek-AI/opencode.git
synced 2026-11-07 10:56:09 -10:00
fix(sdk): apply CORS and service URLs to the embedded HTTP app
This commit is contained in:
1 parent
f955d586a7
commit
8d6737a481
8 files changed
+125
-115
No files matched your search
@@ -731,6 +731,7 @@
|
||||
"effect": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/httpapi-codegen": "workspace:*",
|
||||
"@opencode/protocol": "workspace:*",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/httpapi-codegen": "workspace:*",
|
||||
"@opencode/protocol": "workspace:*",
|
||||
|
||||
@@ -31,7 +31,10 @@ export type Interface = Omit<OpenCodeClient, "plugin" | "workspace"> & {
|
||||
readonly destroy: (options: { readonly workspaceID: Workspace.ID }) => ReturnType<Workspace.Interface["destroy"]>
|
||||
}
|
||||
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"]
|
||||
}
|
||||
|
||||
|
||||
@@ -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<R = never> extends Omit<ServerOptions, "hostname"
|
||||
readonly log?: LogOptions
|
||||
readonly workspaceProviders?: Readonly<Record<string, WorkspaceDriver.Interface>>
|
||||
readonly instances?: SdkInstances.Options<R>
|
||||
/** Addresses where the embedder serves `http`, reported by `/api/info` and used for pairing links. */
|
||||
readonly urls?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
/** Host hooks for embedding opencode on a non-default runtime profile. */
|
||||
@@ -33,7 +36,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* <R = never>(
|
||||
options: CreateOptions<R> = {},
|
||||
embed: EmbedOptions = {},
|
||||
) {
|
||||
const { log, workspaceProviders, instances, ...server } = options
|
||||
const { log, workspaceProviders, instances, urls, ...server } = options
|
||||
const selector = instances ? SdkInstances.provide(instances, yield* Effect.context<R>()) : undefined
|
||||
const runtime = ManagedRuntime.make(
|
||||
createEmbeddedRoutes(
|
||||
@@ -46,6 +49,7 @@ export const create = Effect.fn("EmbeddedHost.create")(function* <R = never>(
|
||||
? [...(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* <R = never>(
|
||||
|
||||
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),
|
||||
|
||||
@@ -17,7 +17,8 @@ export interface InstanceOptions {
|
||||
readonly configure: (key: string) => InstanceConfiguration | Promise<InstanceConfiguration>
|
||||
}
|
||||
|
||||
export interface CreateOptions extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders" | "instances"> {
|
||||
export interface CreateOptions
|
||||
extends Omit<EmbeddedHost.CreateOptions, "workspaceProviders" | "instances" | "password" | "urls"> {
|
||||
readonly plugins?: ReadonlyArray<Plugin.Plugin>
|
||||
readonly instances?: InstanceOptions
|
||||
}
|
||||
|
||||
+105
-41
@@ -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 = <A, E, R>(f: (input: Served) => Effect.Effect<A, E, R>) =>
|
||||
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<string>((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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -100,11 +100,12 @@ export function createEmbeddedRoutes(
|
||||
options: ServerOptions = {},
|
||||
overrides: LayerNode.Replacements = [],
|
||||
instances?: InstanceNode,
|
||||
serviceURLs: () => ReadonlyArray<string> = () => [],
|
||||
) {
|
||||
return makeRoutes(
|
||||
ServerAuth.Config.configLayer({ password: Option.fromNullishOr(options.password) }),
|
||||
options,
|
||||
() => [],
|
||||
serviceURLs,
|
||||
overrides,
|
||||
instances,
|
||||
)
|
||||
|
||||
@@ -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<string>((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")
|
||||
}),
|
||||
)
|
||||
Reference in new issue
Block a user