diff --git a/packages/server/package.json b/packages/server/package.json index 0a574ea56d..427b2619b7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,7 +10,8 @@ }, "scripts": { "test": "bun test --only-failures", - "typecheck": "tsgo -b" + "typecheck": "tsgo -b", + "probe:workerd": "bun run script/workerd-probe.ts" }, "dependencies": { "@effect/platform-node": "catalog:", diff --git a/packages/server/script/workerd-probe.ts b/packages/server/script/workerd-probe.ts new file mode 100644 index 0000000000..73e84a1924 --- /dev/null +++ b/packages/server/script/workerd-probe.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env bun +/** + * Bundle probe for the workerd profile: verifies the full module graph behind + * src/workerd.ts resolves under the `workerd` condition without any `bun:` + * builtins. `node:` builtins stay external (workerd provides them through + * nodejs_compat); the probe prints the surviving externals so the A4 boot + * spike knows exactly what the runtime must supply. + */ +import path from "node:path" +import { mkdtempSync } from "node:fs" +import os from "node:os" + +const outdir = mkdtempSync(path.join(os.tmpdir(), "opencode-workerd-probe-")) +const result = await Bun.build({ + entrypoints: [path.join(import.meta.dir, "../src/workerd.ts")], + conditions: ["workerd"], + target: "node", + outdir, + sourcemap: "none", + throw: false, +}) + +if (!result.success) { + console.error(`workerd bundle probe FAILED (${result.logs.length} issues)`) + for (const log of result.logs) console.error(String(log)) + process.exit(1) +} + +// Everything in the graph is bundled, so any import specifier that survives in +// the output is an external the runtime must provide. +const transpiler = new Bun.Transpiler({ loader: "js" }) +const externals = new Set() +for (const artifact of result.outputs) { + const text = await artifact.text() + for (const imported of transpiler.scanImports(text)) externals.add(imported.path) + for (const match of text.matchAll(/\brequire\(\s*"([^"]+)"\s*\)/g)) externals.add(match[1]) +} +const sorted = Array.from(externals).toSorted() +const bun = sorted.filter((specifier) => specifier === "bun" || specifier.startsWith("bun:")) +const bytes = result.outputs.reduce((total, artifact) => total + artifact.size, 0) + +console.log(`workerd bundle probe OK: ${result.outputs.length} artifacts, ${(bytes / 1024 / 1024).toFixed(1)} MiB`) +console.log(`external builtins (${sorted.length}):`) +for (const specifier of sorted) console.log(` ${specifier}`) +if (bun.length > 0) { + console.error(`FAILED: bun builtins leaked into the workerd graph: ${bun.join(", ")}`) + process.exit(1) +} diff --git a/packages/server/src/fetch.ts b/packages/server/src/fetch.ts index f410524606..14d7e8244a 100644 --- a/packages/server/src/fetch.ts +++ b/packages/server/src/fetch.ts @@ -3,6 +3,7 @@ export * as ServerFetch from "./fetch" import { Context, Effect, Layer } from "effect" import { HttpEffect, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" +import type { LayerNode } from "@opencode-ai/util/effect/layer-node" import { isAllowedCorsOrigin } from "./cors" import { createRoutes } from "./routes" import type { ServerOptions } from "./options" @@ -32,9 +33,18 @@ export interface BootOptions { * Auth follows `createRoutes` semantics: `options.password` enforces Basic auth; omitting it * serves unauthenticated, so an embedder without a password must front the handler with its own * access control. + * + * `overrides` are layer replacements applied after the standard set, so a runtime profile can + * swap services the standard graph assumes are local — see `ServerWorkerd.replacements`. */ -export const make = Effect.fn("ServerFetch.make")(function* (options: ServerOptions = {}, boot: BootOptions = {}) { - const context = yield* Layer.build(createRoutes(options, () => []).pipe(Layer.provide(HttpServer.layerServices))) +export const make = Effect.fn("ServerFetch.make")(function* ( + options: ServerOptions = {}, + overrides: LayerNode.Replacements = [], + boot: BootOptions = {}, +) { + const context = yield* Layer.build( + createRoutes(options, () => [], overrides).pipe(Layer.provide(HttpServer.layerServices)), + ) // Forked so the returned handler is never delayed; resumed drains are already // logged and durably recorded by the execution layer. if (boot.resumeSuspendedSessions) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 965522466c..b977eb0544 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -66,27 +66,34 @@ const applicationServices = LayerNode.group([ SessionRestart.node, ]) -export function createRoutes(options: ServerOptions = {}, serviceURLs: () => ReadonlyArray = () => []) { +export function createRoutes( + options: ServerOptions = {}, + serviceURLs: () => ReadonlyArray = () => [], + overrides: LayerNode.Replacements = [], +) { return makeRoutes( options.password ? ServerAuth.Config.configLayer({ password: Option.some(options.password) }) : ServerAuth.Config.layer, options, serviceURLs, + overrides, ) } export function createEmbeddedRoutes(options: ServerOptions = {}) { - return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => []) + return makeRoutes(ServerAuth.Config.configLayer({ password: Option.none() }), options, () => [], []) } function makeRoutes( auth: Layer.Layer, options: ServerOptions, serviceURLs: () => ReadonlyArray, + // Runtime-profile replacements (e.g. workerd) applied after the standard set, so later entries win. + overrides: LayerNode.Replacements, ) { const pluginRuntimeCell = PluginRuntime.makeCell() - const replacements: LayerNode.Replacements = [ + const standard: LayerNode.Replacements = [ [Database.node, Database.configured(options.database)], [Bus.node, Bus.configured({ persist: options.events?.persist })], [App.node, App.configured(options.app)], @@ -122,6 +129,7 @@ function makeRoutes( WorkspaceDriver.registryNode({ [modalProvider]: modalWorkspaceDriver({ app: "opencode-workspaces" }) }), ], ] + const replacements: LayerNode.Replacements = [...standard, ...overrides] const serviceLayer = options.simulation ? Layer.unwrap( Effect.gen(function* () { diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts new file mode 100644 index 0000000000..243a2bd3ca --- /dev/null +++ b/packages/server/src/workerd.ts @@ -0,0 +1,166 @@ +export * as ServerWorkerd from "./workerd" + +import { Effect, Layer } from "effect" +import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source" +import { Database } from "@opencode-ai/core/database/database" +import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd" +import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd" +import { FileSystem } from "@opencode-ai/core/filesystem" +import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" +import { MCP } from "@opencode-ai/core/mcp/index" +import { Pty } from "@opencode-ai/core/pty" +import { Shell } from "@opencode-ai/core/shell" +import { Snapshot } from "@opencode-ai/core/snapshot" +import { Vcs } from "@opencode-ai/core/vcs" +import { Global } from "@opencode-ai/util/global" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" +import type { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { ServerFetch } from "./fetch" +import type { ServerOptions } from "./options" + +/** + * The workerd runtime profile: boots opencode core and server inside a + * Cloudflare Durable Object, with every intentionally-local service replaced + * or disabled. + * + * - Database runs on the injected `DurableObjectStorage` SQLite. + * - Watcher and fff are disabled through their existing option flags; pty, fff, + * shell-parser, photon, and process-lock native modules resolve to inert + * stubs under the `workerd` bundle condition. + * - Shell, FileSystem, FileSystemSearch, and Pty fail with a clear defect until + * a remote sandbox backs them; Snapshot and Vcs degrade to no-op results. + * - Config is injected as a string (no filesystem); plugin discovery is + * precompiled-only and MCP is restricted to remote transports. + * + * Bundle with the `workerd` condition, e.g. + * `bun build src/workerd.ts --conditions=workerd --target=node` + * (see `script/workerd-probe.ts`). + */ +export interface Options { + /** Durable Object storage whose SQLite database backs the opencode database. */ + readonly storage: DurableObjectStorage + readonly app?: ServerOptions["app"] + readonly password?: string + /** Inline opencode config content (JSON), same as `ServerOptions.config.content`. */ + readonly config?: { readonly content?: string } + /** models.dev catalog source; the bundled snapshot is the boot-time floor either way. */ + readonly models?: { readonly url?: string } + /** Overrides for the injected Global paths; defaults root everything under tmp on workerd. */ + readonly paths?: Partial +} + +/** + * Builds the web-standard fetch handler for a Durable Object's `fetch()`. The + * application layer builds eagerly in the caller's scope, so hold it in the + * Durable Object instance rather than per request. + */ +export function create(options: Options) { + // Eviction can kill the isolate between a turn's Started and terminal events with no + // teardown. The write-ahead execution claim plus this boot-time resume recovers such + // orphaned turns by replaying the drain from durable history on the next wake. + return ServerFetch.make(serverOptions(options), replacements(options), { resumeSuspendedSessions: true }) +} + +export function serverOptions(options: Options): ServerOptions { + return { + app: options.app, + password: options.password, + fs: { filewatcher: false, fff: false }, + config: { content: options.config?.content }, + models: { url: options.models?.url }, + } +} + +/** The workerd replacement graph, applied after the standard server replacements. */ +export function replacements(options: Options): LayerNode.Replacements { + return [ + [ + Database.node, + makeGlobalNode({ + service: Database.Service, + layer: Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage: options.storage }))), + deps: [Global.node], + }), + ], + [Global.node, Global.layerWith({ ...options.paths })], + [Snapshot.node, Snapshot.noopLayer], + [Vcs.node, vcsLayer], + [Shell.node, shellLayer], + [FileSystem.node, fileSystemLayer], + [FileSystemSearch.node, fileSystemSearchLayer], + [Pty.node, ptyLayer], + [ + MCP.node, + MCP.configured({ + clientInfo: { + name: options.app?.name ?? "opencode", + version: options.app?.version ?? "unknown", + }, + stdio: false, + }), + ], + // Precompiled (internal and SDK) plugins only: no plugin-directory scan, npm + // install, or import of plugin code from disk. + [ConfigPluginSource.node, ConfigPluginSource.empty], + ] satisfies LayerNode.Replacements +} + +const unavailable = (what: string) => Effect.die(new Error(`${what} is unavailable in the workerd profile`)) + +// Vcs degrades to empty results, matching its behavior for locations without a +// supported VCS, so read-only clients never need to special-case this runtime. +const vcsLayer = Layer.succeed( + Vcs.Service, + Vcs.Service.of({ + info: () => Effect.succeed({ branch: {} }), + status: () => Effect.succeed([]), + diff: () => Effect.succeed([]), + }), +) + +// Shell commands need a real process; queries for unknown IDs stay typed while +// creation is a defect until a remote sandbox backs them. +const shellLayer = Layer.succeed( + Shell.Service, + Shell.Service.of({ + name: () => Effect.succeed("unsupported"), + create: () => unavailable("Shell.create"), + list: () => Effect.succeed([]), + get: (id) => Effect.fail(new Shell.NotFoundError({ id })), + wait: (id) => Effect.fail(new Shell.NotFoundError({ id })), + timeout: (id) => Effect.fail(new Shell.NotFoundError({ id })), + output: (id) => Effect.fail(new Shell.NotFoundError({ id })), + remove: (id) => Effect.fail(new Shell.NotFoundError({ id })), + }), +) + +// The Location-scoped filesystem has no local worktree to serve until a remote +// sandbox backs it. +const fileSystemLayer = Layer.succeed( + FileSystem.Service, + FileSystem.Service.of({ + read: () => unavailable("FileSystem.read"), + list: () => unavailable("FileSystem.list"), + find: () => unavailable("FileSystem.find"), + }), +) + +const fileSystemSearchLayer = Layer.succeed( + FileSystemSearch.Service, + FileSystemSearch.Service.of({ + find: () => unavailable("FileSystemSearch.find"), + }), +) + +const ptyLayer = Layer.succeed( + Pty.Service, + Pty.Service.of({ + list: () => Effect.succeed([]), + get: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + create: () => unavailable("Pty.create"), + update: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + remove: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + write: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + attach: (ptyID) => Effect.fail(new Pty.NotFoundError({ ptyID })), + }), +) diff --git a/packages/server/test/workerd.test.ts b/packages/server/test/workerd.test.ts new file mode 100644 index 0000000000..0e3fc5b2ee --- /dev/null +++ b/packages/server/test/workerd.test.ts @@ -0,0 +1,34 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { makeDurableObjectStorage } from "../../core/test/fixture/durable-object-storage" +import { it } from "../../core/test/lib/effect" +import { ServerWorkerd } from "../src/workerd" + +// Covers the profile's replacement graph composing and the database booting +// through the injected Durable Object storage. Verification inside a real +// isolate lives in the workerd-spike package. +it.live("boots the workerd profile over durable object storage", () => + Effect.gen(function* () { + const handler = yield* ServerWorkerd.create({ + storage: makeDurableObjectStorage(), + password: "secret", + app: { version: "workerd-test" }, + config: { content: "{}" }, + }) + + const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health"))) + expect(unauthorized.status).toBe(401) + + const health = yield* Effect.promise(() => + handler( + new Request("http://opencode.local/api/health", { + headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + }), + ), + ) + expect(health.status).toBe(200) + + const body: unknown = yield* Effect.promise(() => health.json()) + expect(body).toMatchObject({ healthy: true, version: "workerd-test" }) + }).pipe(Effect.scoped), +)