feat(sdk): expose the embedded instance's HTTP app with password auth by kommander · Pull Request #53736 · anomalyco/opencode · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/sdk/package.json
8 changes: 8 additions & 0 deletions packages/sdk/src/effect/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ 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, with CORS but without response compression.
* Serve it with an Effect `HttpServer`; a fetch handler cannot accept PTY WebSockets. Stop that server
* before closing this instance. Node's server waits for open SSE streams, so call `closeAllConnections()`
* when stopping it. Bun closes requests idle for 10 seconds and SSE heartbeats every 15, so set `idleTimeout: 0`.
*/
readonly http: EmbeddedHost.Interface["http"]
}

export const create: <R = never>(
Expand Down Expand Up @@ -64,6 +71,7 @@ export const create: <R = never>(
destroy: ({ workspaceID }: { readonly workspaceID: Workspace.ID }) => host.workspace.destroy(workspaceID),
},
plugin: Object.assign(host.plugins.register, client.plugin),
http: host.http,
}
})

Expand Down
36 changes: 31 additions & 5 deletions packages/sdk/src/internal/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,29 @@ 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"

export interface CreateOptions<R = never> extends Omit<ServerOptions, "hostname" | "port" | "password"> {
/**
* `password` requires Basic auth on `http`, as `opencode serve` does; in-process SDK calls are authorized
* automatically.
*/
export interface CreateOptions<R = never> extends Omit<ServerOptions, "hostname" | "port"> {
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. Read on
* every request, so addresses known only after startup are reported once they exist.
*/
readonly urls?: () => ReadonlyArray<string>
}

/** Host hooks for embedding opencode on a non-default runtime profile. */
Expand All @@ -29,7 +39,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(
Expand All @@ -42,6 +52,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))),
)

Expand All @@ -50,13 +61,28 @@ export const create = Effect.fn("EmbeddedHost.create")(function* <R = never>(
// The sweep is a no-op when nothing is suspended. ManagedRuntime owns the
// fiber so recovery never delays startup but still stops with the host.
runtime.runFork(Context.get(services, SessionRestart.Service).resumeSuspendedSessions)
const http = Context.get(services, HttpRouter.HttpRouter).asHttpEffect()
const handler = HttpEffect.toWebHandlerWith<never, HttpServerRequest.HttpServerRequest | Scope.Scope>(
context(services),
)(Context.get(services, HttpRouter.HttpRouter).asHttpEffect())
const transport = OwnedFetch.make(handler, runtime.dispose)
)(http)
const authorization = server.password && `Basic ${btoa(`opencode:${server.password}`)}`
const transport = OwnedFetch.make(
authorization
? (request) => {
const authorized = new Request(request)
authorized.headers.set("authorization", authorization)
return handler(authorized)
}
: handler,
runtime.dispose,
)

return {
runtime,
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),
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk/src/promise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
143 changes: 143 additions & 0 deletions packages/sdk/test/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { createServer } from "node:http"
import { expect } from "bun:test"
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
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")}` }

// 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
urls: Array<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]()),
).pipe(
Effect.flatMap((directory) =>
Effect.gen(function* () {
const sdk = yield* Effect.promise(() => import("../src/effect"))
const urls: Array<string> = []
const opencode = yield* sdk.OpenCode.create({
config: { directory: directory.path, project: false, content: "{}" },
models: { fetch: false },
fs: { filewatcher: false },
password: "secret",
urls: () => urls,
})
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,
urls,
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, urls }) =>
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)
// Addresses known only after startup are reported once they exist.
expect((await (await fetch(`${base}/api/info`, { headers: auth })).json()).urls).toEqual([])
urls.push("https://opencode.example")
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")
}),
),
)
9 changes: 8 additions & 1 deletion packages/server/src/routes.ts
Loading