From c680feda35104adbef9248b10df36da7c560af87 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 21:41:51 -0400 Subject: [PATCH] feat(core): workerd runtime stubs for bundles without a process Resolve the native-module import conditions (#pty, #fff, #photon-wasm, #shell-parser-wasm, #process-lock-ffi) to inert workerd stubs, so the module graph loads in a runtime with no subprocesses, FFI, or filesystem artifacts. Loopback OAuth servers import node:http lazily for the same reason, MCP gains an stdio flag for runtimes that cannot spawn local servers, and Global roots every path under one writable directory (tmp on workerd, OPENCODE_GLOBAL_ROOT anywhere). --- packages/core/package.json | 5 + packages/core/src/filesystem/fff.workerd.ts | 119 ++++++++++++++++++ .../core/src/image/photon-wasm.workerd.ts | 4 + packages/core/src/mcp/index.ts | 7 ++ packages/core/src/mcp/oauth.ts | 3 +- packages/core/src/plugin/provider/openai.ts | 3 +- packages/core/src/pty/pty.workerd.ts | 9 ++ .../core/src/shell/parser-wasm.workerd.ts | 4 + .../core/src/util/process-lock-ffi.workerd.ts | 15 +++ .../test/fixture/durable-object-storage.ts | 47 +++++++ packages/core/test/sqlite-workerd.test.ts | 56 +-------- packages/util/package.json | 1 + packages/util/src/global.ts | 15 ++- 13 files changed, 231 insertions(+), 57 deletions(-) create mode 100644 packages/core/src/filesystem/fff.workerd.ts create mode 100644 packages/core/src/image/photon-wasm.workerd.ts create mode 100644 packages/core/src/pty/pty.workerd.ts create mode 100644 packages/core/src/shell/parser-wasm.workerd.ts create mode 100644 packages/core/src/util/process-lock-ffi.workerd.ts create mode 100644 packages/core/test/fixture/durable-object-storage.ts diff --git a/packages/core/package.json b/packages/core/package.json index 1d98d93c6ba..719f449bd80 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -36,26 +36,31 @@ "default": "./src/database/sqlite.node.ts" }, "#pty": { + "workerd": "./src/pty/pty.workerd.ts", "bun": "./src/pty/pty.bun.ts", "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" }, "#fff": { + "workerd": "./src/filesystem/fff.workerd.ts", "bun": "./src/filesystem/fff.bun.ts", "node": "./src/filesystem/fff.node.ts", "default": "./src/filesystem/fff.bun.ts" }, "#photon-wasm": { + "workerd": "./src/image/photon-wasm.workerd.ts", "bun": "./src/image/photon-wasm.bun.ts", "node": "./src/image/photon-wasm.node.ts", "default": "./src/image/photon-wasm.bun.ts" }, "#shell-parser-wasm": { + "workerd": "./src/shell/parser-wasm.workerd.ts", "bun": "./src/shell/parser-wasm.bun.ts", "node": "./src/shell/parser-wasm.node.ts", "default": "./src/shell/parser-wasm.bun.ts" }, "#process-lock-ffi": { + "workerd": "./src/util/process-lock-ffi.workerd.ts", "bun": "./src/util/process-lock-ffi.bun.ts", "node": "./src/util/process-lock-ffi.node.ts", "default": "./src/util/process-lock-ffi.bun.ts" diff --git a/packages/core/src/filesystem/fff.workerd.ts b/packages/core/src/filesystem/fff.workerd.ts new file mode 100644 index 00000000000..daf306225a7 --- /dev/null +++ b/packages/core/src/filesystem/fff.workerd.ts @@ -0,0 +1,119 @@ +import type { + DirItem, + DirSearchResult, + FileItem, + GrepCursor, + GrepMatch, + GrepResult, + InitOptions, + MixedItem, + MixedSearchResult, + SearchResult, +} from "@ff-labs/fff-node" + +export type Result = { ok: true; value: T } | { ok: false; error: string } + +export type Init = InitOptions + +export interface Search { + items: FileItem[] + scores: SearchResult["scores"] + totalMatched: number + totalFiles: number +} + +export interface DirSearch { + items: DirItem[] + scores: DirSearchResult["scores"] + totalMatched: number + totalDirs: number +} + +export interface MixedSearch { + items: MixedItem[] + scores: MixedSearchResult["scores"] + totalMatched: number + totalFiles: number + totalDirs: number +} + +export type File = FileItem +export type Directory = DirItem +export type Mixed = MixedItem +export type Cursor = GrepCursor | null +export type Hit = GrepMatch + +export interface Grep { + items: GrepResult["items"] + totalMatched: number + totalFilesSearched: number + totalFiles: number + filteredFileCount: number + nextCursor: Cursor + regexFallbackError?: string +} + +export interface Picker { + destroy(): void + isScanning(): boolean + waitForScan(timeoutMs?: number): Promise> + refreshGitStatus(): Result + fileSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + glob( + pattern: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + directorySearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + mixedSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + grep( + query: string, + opts?: { + mode?: "plain" | "regex" | "fuzzy" + maxMatchesPerFile?: number + timeBudgetMs?: number + beforeContext?: number + afterContext?: number + cursor?: Cursor + pageSize?: number + }, + ): Result + trackQuery(query: string, file: string): Result + getHistoricalQuery(offset: number): Result +} + +// workerd cannot load the fff native binding; reporting unavailable makes +// FileSystemSearch fall back to its non-fff layer. +export function available() { + return false +} + +export function create(_opts: Init): Result { + return { ok: false, error: "fff unavailable on workerd runtime" } +} + +export * as Fff from "./fff.workerd" diff --git a/packages/core/src/image/photon-wasm.workerd.ts b/packages/core/src/image/photon-wasm.workerd.ts new file mode 100644 index 00000000000..6207c54ce48 --- /dev/null +++ b/packages/core/src/image/photon-wasm.workerd.ts @@ -0,0 +1,4 @@ +// workerd has no filesystem path to a photon wasm artifact. Image.Photon only +// reads this lazily and surfaces a typed ResizerUnavailableError when loading +// fails, so an empty path degrades cleanly instead of breaking module load. +export default "" diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index c1020cbff41..2bf7be1eab7 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -164,6 +164,8 @@ export const Options = Schema.Struct({ version: Schema.String, }), ), + /** Set false on runtimes that cannot spawn child processes; local (stdio) servers report failed instead of connecting. */ + stdio: Schema.optional(Schema.Boolean), }) export type Options = typeof Options.Type @@ -502,6 +504,11 @@ export const layer = (options?: Options) => const startServer = (name: ServerName, entry: ServerEntry) => Effect.gen(function* () { + if (options?.stdio === false && entry.config.type === "local") { + entry.status = { status: "failed", error: "stdio MCP servers are unavailable in this runtime" } + yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + return + } // Announce the handshake so connect() and credential reconnects don't show a stale // disabled/failed status for the duration of the connection attempt. entry.status = { status: "pending" } diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts index 1fda70dbd61..b6a87b9b935 100644 --- a/packages/core/src/mcp/oauth.ts +++ b/packages/core/src/mcp/oauth.ts @@ -2,7 +2,6 @@ export * as MCPOAuth from "./oauth.js" import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js" -import { createServer } from "node:http" import { Deferred, Effect } from "effect" import { Credential } from "@opencode-ai/schema/credential" import { ConfigMCP } from "@opencode-ai/schema/config/mcp" @@ -152,6 +151,8 @@ export const authorize = (input: { const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback" const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url") + // Lazy so runtimes without a loopback listener (workerd) never evaluate node:http. + const { createServer } = yield* Effect.promise(() => import("node:http")) const server = createServer((request, response) => { const url = new URL(request.url ?? "/", "http://127.0.0.1") if (url.pathname !== redirectPath) { diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 15437e7c89d..2aeded888d0 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -1,4 +1,3 @@ -import { createServer } from "node:http" import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration" import { define } from "@opencode-ai/plugin/effect/plugin" import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect" @@ -58,6 +57,8 @@ const browser = (app: App.Info) => const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) const code = yield* Deferred.make() const redirect = `http://localhost:${callbackPort}/auth/callback` + // Lazy so runtimes without a loopback listener (workerd) never evaluate node:http. + const { createServer } = yield* Effect.promise(() => import("node:http")) const server = createServer((request, response) => { const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) if (url.pathname !== "/auth/callback") { diff --git a/packages/core/src/pty/pty.workerd.ts b/packages/core/src/pty/pty.workerd.ts new file mode 100644 index 00000000000..25869036b51 --- /dev/null +++ b/packages/core/src/pty/pty.workerd.ts @@ -0,0 +1,9 @@ +import type { Proc } from "./pty" + +export type { Disp, Exit, Opts, Proc } from "./pty" + +// workerd cannot spawn processes; the Pty service surfaces this as a defect if +// a terminal is ever requested on this runtime. +export function spawn(): Proc { + throw new Error("Pseudo-terminals are unavailable on the workerd runtime") +} diff --git a/packages/core/src/shell/parser-wasm.workerd.ts b/packages/core/src/shell/parser-wasm.workerd.ts new file mode 100644 index 00000000000..394d517a042 --- /dev/null +++ b/packages/core/src/shell/parser-wasm.workerd.ts @@ -0,0 +1,4 @@ +// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse +// loads these lazily and degrades when initialization fails, so empty paths +// keep module load side-effect free instead of resolving from disk. +export const shellParserWasm = { runtime: "", bash: "", powershell: "" } diff --git a/packages/core/src/util/process-lock-ffi.workerd.ts b/packages/core/src/util/process-lock-ffi.workerd.ts new file mode 100644 index 00000000000..d34eeba9eab --- /dev/null +++ b/packages/core/src/util/process-lock-ffi.workerd.ts @@ -0,0 +1,15 @@ +export type LockResult = + | { readonly acquired: true } + | { readonly acquired: false; readonly held: true } + | { readonly acquired: false; readonly held: false; readonly code: number } + +// workerd has no FFI and no cross-process file locking; a Durable Object is +// already single-threaded per instance, so nothing on this runtime should +// reach these. +export function lockDarwin(): LockResult { + throw new Error("Process locks are unavailable on the workerd runtime") +} + +export function lockLinux(): LockResult { + throw new Error("Process locks are unavailable on the workerd runtime") +} diff --git a/packages/core/test/fixture/durable-object-storage.ts b/packages/core/test/fixture/durable-object-storage.ts new file mode 100644 index 00000000000..00bf8f4a630 --- /dev/null +++ b/packages/core/test/fixture/durable-object-storage.ts @@ -0,0 +1,47 @@ +import { Database } from "bun:sqlite" +import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd" + +// Emulates the Durable Object storage API over bun:sqlite so the workerd +// adapter and the workerd server profile can be verified without workerd or +// Cloudflare runtime dependencies. The real runtime is covered by the +// workerd-spike package, which boots inside an actual isolate. +export const makeDurableObjectStorage = (): DurableObjectStorage => { + const native = new Database(":memory:") + const toSqlStorageValue = (value: unknown) => { + if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null + const buffer = new ArrayBuffer(value.byteLength) + new Uint8Array(buffer).set(value) + return buffer + } + return { + sql: { + exec(query: string, ...bindings: Array) { + const statement = native.query(query) + const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue)) + const columnNames = statement.columnNames + return { + columnNames, + raw: () => rows[Symbol.iterator](), + toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))), + } + }, + }, + transaction(closure: (txn: { rollback(): void }) => Promise): Promise { + native.run("BEGIN") + let rolledBack = false + return closure({ rollback: () => (rolledBack = true) }).then( + (result) => { + native.run(rolledBack ? "ROLLBACK" : "COMMIT") + return result + }, + (error) => { + native.run("ROLLBACK") + throw error + }, + ) + }, + transactionSync(closure: () => T): T { + return native.transaction(closure)() + }, + } +} diff --git a/packages/core/test/sqlite-workerd.test.ts b/packages/core/test/sqlite-workerd.test.ts index 64194b04187..e539b5efe8a 100644 --- a/packages/core/test/sqlite-workerd.test.ts +++ b/packages/core/test/sqlite-workerd.test.ts @@ -1,63 +1,19 @@ import { describe, expect, test } from "bun:test" -import { Database } from "bun:sqlite" import { Effect, Layer } from "effect" import { SqlClient } from "effect/unstable/sql" import { SqlError } from "effect/unstable/sql/SqlError" import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd" import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd" +import { makeDurableObjectStorage } from "./fixture/durable-object-storage" import { tempGlobalLayer } from "./fixture/global" -// Emulates the Durable Object storage API over bun:sqlite so the adapter can -// be verified without workerd or Cloudflare runtime dependencies. -const makeFakeStorage = () => { - const native = new Database(":memory:") - const toSqlStorageValue = (value: unknown) => { - if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null - const buffer = new ArrayBuffer(value.byteLength) - new Uint8Array(buffer).set(value) - return buffer - } - const storage: DurableObjectStorage = { - sql: { - exec(query: string, ...bindings: Array) { - const statement = native.query(query) - const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue)) - const columnNames = statement.columnNames - return { - columnNames, - raw: () => rows[Symbol.iterator](), - toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))), - } - }, - }, - transaction(closure: (txn: { rollback(): void }) => Promise): Promise { - native.run("BEGIN") - let rolledBack = false - return closure({ rollback: () => (rolledBack = true) }).then( - (result) => { - native.run(rolledBack ? "ROLLBACK" : "COMMIT") - return result - }, - (error) => { - native.run("ROLLBACK") - throw error - }, - ) - }, - transactionSync(closure: () => T): T { - return native.transaction(closure)() - }, - } - return storage -} - const run = (storage: DurableObjectStorage, effect: Effect.Effect) => Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped)) describe("sqlite.workerd", () => { test("executes statements with bindings and maps rows to records", async () => { const rows = await run( - makeFakeStorage(), + makeDurableObjectStorage(), Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)` @@ -73,7 +29,7 @@ describe("sqlite.workerd", () => { test("normalizes ArrayBuffer blob values to Uint8Array", async () => { const rows = await run( - makeFakeStorage(), + makeDurableObjectStorage(), Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql`CREATE TABLE blob (data BLOB NOT NULL)` @@ -86,7 +42,7 @@ describe("sqlite.workerd", () => { }) test("withTransaction commits on success and rolls back on failure", async () => { - const storage = makeFakeStorage() + const storage = makeDurableObjectStorage() const count = await run( storage, Effect.gen(function* () { @@ -109,7 +65,7 @@ describe("sqlite.workerd", () => { test("nested withTransaction fails with SqlError", async () => { const error = await run( - makeFakeStorage(), + makeDurableObjectStorage(), Effect.gen(function* () { const sql = yield* SqlClient.SqlClient yield* sql`CREATE TABLE t (value TEXT NOT NULL)` @@ -122,7 +78,7 @@ describe("sqlite.workerd", () => { }) test("boots the full database layer with migrations over injected storage", async () => { - const storage = makeFakeStorage() + const storage = makeDurableObjectStorage() const core = await import("@opencode-ai/core/database/database") await Effect.runPromise( Effect.scoped( diff --git a/packages/util/package.json b/packages/util/package.json index 6612d6737d1..c6223225206 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -21,6 +21,7 @@ }, "imports": { "#runtime-import": { + "workerd": "./src/runtime/import.bun.ts", "bun": "./src/runtime/import.bun.ts", "node": "./src/runtime/import.node.ts", "default": "./src/runtime/import.bun.ts" diff --git a/packages/util/src/global.ts b/packages/util/src/global.ts index 3e05ca97597..7d9e74a6dfa 100644 --- a/packages/util/src/global.ts +++ b/packages/util/src/global.ts @@ -7,11 +7,16 @@ import { Flock } from "./flock.js" import { makeGlobalNode } from "./effect/app-node.js" const app = "opencode" -const data = path.join(xdgData!, app) -const cache = path.join(xdgCache!, app) -const config = path.join(xdgConfig!, app) -const state = path.join(xdgState!, app) -const tmp = path.join(os.tmpdir(), app) +// workerd has no home directory or XDG base dirs and only tmp is writable, so +// root every global path under one directory there. OPENCODE_GLOBAL_ROOT +// overrides the root on any runtime. +const workerd = (globalThis as { navigator?: { userAgent?: string } }).navigator?.userAgent === "Cloudflare-Workers" +const root = process.env.OPENCODE_GLOBAL_ROOT ?? (workerd ? path.join(os.tmpdir(), app) : undefined) +const data = root ? path.join(root, "data") : path.join(xdgData!, app) +const cache = root ? path.join(root, "cache") : path.join(xdgCache!, app) +const config = root ? path.join(root, "config") : path.join(xdgConfig!, app) +const state = root ? path.join(root, "state") : path.join(xdgState!, app) +const tmp = root ? path.join(root, "tmp") : path.join(os.tmpdir(), app) const paths = { get home() {