diff --git a/packages/core/package.json b/packages/core/package.json index 9844abe63d..4062fb7c84 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,26 +32,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 0000000000..daf306225a --- /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 0000000000..6207c54ce4 --- /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 c1020cbff4..2bf7be1eab 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 1fda70dbd6..b6a87b9b93 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 15437e7c89..2aeded888d 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 0000000000..25869036b5 --- /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 0000000000..394d517a04 --- /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 0000000000..d34eeba9ea --- /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 0000000000..00bf8f4a63 --- /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 64194b0418..e539b5efe8 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 6612d6737d..c622322520 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 3e05ca9759..7d9e74a6df 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() {