diff --git a/packages/core/src/environment/unavailable.ts b/packages/core/src/environment/unavailable.ts new file mode 100644 index 00000000000..59db611a77f --- /dev/null +++ b/packages/core/src/environment/unavailable.ts @@ -0,0 +1,17 @@ +import { Effect, Layer, PlatformError } from "effect" +import { ChildProcessSpawner, make } from "effect/unstable/process/ChildProcessSpawner" + +export const spawner = make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "Unknown", + module: "Environment", + method: "spawn", + description: "This location has no execution plane: no workspace is attached and the host cannot spawn processes", + }), + ), +) + +export const layer = Layer.succeed(ChildProcessSpawner, spawner) + +export * as EnvironmentUnavailable from "./unavailable.js" diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 9504e79e89f..4685bc31eb5 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -165,8 +165,6 @@ 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 @@ -500,11 +498,6 @@ 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/session.ts b/packages/core/src/session.ts index bbffc947ec5..ebe91c5ccba 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -639,7 +639,9 @@ const layer = Layer.effect( yield* execution.awaitIdle(input.sessionID) const started = yield* Effect.gen(function* () { const shell = yield* Shell.Service - return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 }) + return yield* shell + .create({ command: input.command, cwd: session.location.directory, timeout: 0 }) + .pipe(Effect.orDie) }).pipe(Effect.provide(locations.get(session.location))) yield* bus.publish( SessionEvent.Shell.Started, diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 38e8c1cc0db..d1733732be0 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -5,6 +5,7 @@ import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } fro import { ChildProcess } from "effect/unstable/process" import { produce } from "immer" import { Shell } from "@opencode-ai/schema/shell" +import { AppProcess } from "@opencode-ai/util/process" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Config } from "./config.js" import { Bus } from "./bus.js" @@ -50,7 +51,7 @@ export interface Interface { readonly create: ( input: Shell.CreateInput, before?: (input: ShellCreateBefore) => Effect.Effect, - ) => Effect.Effect + ) => Effect.Effect // Currently running commands only; exited shells are retained for get/output but excluded here. readonly list: () => Effect.Effect readonly get: (id: Shell.ID) => Effect.Effect @@ -215,19 +216,23 @@ export const layer = (options?: ShellSelect.Options) => // Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so // the managing fiber keeps its scope open until the command terminates (it awaits `done` at the // end). `create` returns once `ready` resolves with the registered session. - const ready = Deferred.makeUnsafe() + const ready = Deferred.makeUnsafe() runFork( Effect.scoped( Effect.gen(function* () { - const handle = yield* environment.spawner.spawn( - ChildProcess.make(invocation.shell, args, { - cwd: invocation.cwd, - env: invocation.env, - stdin: "ignore", - detached: process.platform !== "win32", - forceKillAfter: Duration.seconds(3), - }), - ) + const handle = yield* environment.spawner + .spawn( + ChildProcess.make(invocation.shell, args, { + cwd: invocation.cwd, + env: invocation.env, + stdin: "ignore", + detached: process.platform !== "win32", + forceKillAfter: Duration.seconds(3), + }), + ) + .pipe( + Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })), + ) const session: Active = { info: produce(info, (draft) => { draft.pid = handle.pid @@ -329,7 +334,7 @@ export const layer = (options?: ShellSelect.Options) => // release (kill) the process before its exit is observed. yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void)) }), - ).pipe(Effect.catch(() => Effect.void)), + ).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))), ) const session = yield* Deferred.await(ready) diff --git a/packages/core/test/environment.test.ts b/packages/core/test/environment.test.ts index 90246bd02be..1571df10f53 100644 --- a/packages/core/test/environment.test.ts +++ b/packages/core/test/environment.test.ts @@ -1,9 +1,10 @@ import fs from "node:fs/promises" import { describe, expect } from "bun:test" import { Effect } from "effect" -import { ChildProcessSpawner } from "effect/unstable/process" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner" import { LayerNode } from "@opencode-ai/util/effect/layer-node" +import { EnvironmentUnavailable } from "../src/environment/unavailable" import { execDefaults, Failed, @@ -35,6 +36,19 @@ describe("typeFollowing", () => { ) }) +describe("no execution plane", () => { + it.effect("fails spawn with a typed location error", () => + Effect.gen(function* () { + const error = yield* EnvironmentUnavailable.spawner + .spawn(ChildProcess.make("echo", ["hello"])) + .pipe(Effect.flip) + + expect(error._tag).toBe("PlatformError") + expect(error.message).toContain("location has no execution plane") + }), + ) +}) + environmentConformance("memory environment", () => Effect.sync(() => { const driver = makeMemoryDriver() diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 9c2e49c628b..cc879b8d388 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -23,6 +23,7 @@ import { ID, type Payload } from "@opencode-ai/schema/event" import { Form } from "@opencode-ai/core/form" import { Integration } from "@opencode-ai/core/integration" import { Environment } from "@opencode-ai/core/environment/index" +import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable" import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" import { MCPClient } from "@opencode-ai/core/mcp/client" @@ -478,6 +479,27 @@ test("spawns local MCP servers through the location environment", async () => { expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" }) }) +test("reports a local MCP server as failed when the location has no execution plane", async () => { + const config = new ConfigMCP.Local({ type: "local", command: ["example-mcp"] }) + const driver = Environment.makeMemoryDriver() + const environment = Layer.succeed( + Environment.Service, + Environment.Service.of({ files: Environment.makeFiles(driver), spawner: EnvironmentUnavailable.spawner }), + ) + + await Effect.runPromise( + Effect.gen(function* () { + const service = yield* MCP.Service + yield* service.tools() + const status = (yield* service.servers()).find((server) => server.name === "resources")?.status + expect(status).toEqual({ + status: "failed", + error: expect.stringContaining("location has no execution plane"), + }) + }).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { environment }))), + ) +}) + test("rejects sends before the stdio transport is started", async () => { await Effect.runPromise( Effect.scoped( diff --git a/packages/server/src/handlers/shell.ts b/packages/server/src/handlers/shell.ts index 4b0f83bed5e..96d7530cd81 100644 --- a/packages/server/src/handlers/shell.ts +++ b/packages/server/src/handlers/shell.ts @@ -21,7 +21,9 @@ export const ShellHandler = HttpApiBuilder.group(Api, "server.shell", (handlers) Effect.fn(function* (ctx) { const shell = yield* Shell.Service const location = yield* Location.Service - return yield* response(shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory })) + return yield* response( + shell.create({ ...ctx.payload, cwd: ctx.payload.cwd || location.directory }).pipe(Effect.orDie), + ) }), ) .handle( diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index 5ed50164a3c..96a70bd1800 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -40,11 +40,5 @@ export const ServerOptions = Schema.Struct({ fff: Schema.optional(Schema.Boolean), }), ), - mcp: Schema.optional( - Schema.Struct({ - /** Set false on runtimes that cannot spawn child processes; local (stdio) MCP servers report failed instead of connecting. */ - stdio: Schema.optional(Schema.Boolean), - }), - ), }) export type ServerOptions = typeof ServerOptions.Type diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index dd9272e3103..b91ff0bd7d2 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -120,7 +120,6 @@ function makeRoutes( name: options.app?.name ?? "opencode", version: options.app?.version ?? "unknown", }, - stdio: options.mcp?.stdio, }), ], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index 6ed7fefde71..fa628f31d4f 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -5,12 +5,13 @@ 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 { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable" import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystemSearch } from "@opencode-ai/core/filesystem/search" 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 { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner" import type { LayerNode } from "@opencode-ai/util/effect/layer-node" import { ServerFetch } from "./fetch" import type { ServerOptions } from "./options" @@ -24,10 +25,11 @@ import type { ServerOptions } from "./options" * - 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. + * - Bare locations use a typed no-execution-plane process spawner; 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. + * precompiled-only, and stdio MCP reports the same no-plane failure as Shell. * * Bundle with the `workerd` condition, e.g. * `bun build src/workerd.ts --conditions=workerd --target=node` @@ -67,9 +69,6 @@ export function serverOptions(options: Options): ServerOptions { events: { persist: true }, config: { content: options.config?.content }, models: options.models, - // No child processes on workerd: local (stdio) MCP servers report failed - // instead of connecting; remote transports work unchanged. - mcp: { stdio: false }, } } @@ -77,9 +76,9 @@ export function serverOptions(options: Options): ServerOptions { export function replacements(options: Options): LayerNode.Replacements { return [ [Database.node, Database.configuredClient(sqliteLayer({ storage: options.storage }))], + [CrossSpawnSpawner.node, EnvironmentUnavailable.layer], [Snapshot.node, Snapshot.noopLayer], [Vcs.node, vcsLayer], - [Shell.node, shellLayer], [FileSystem.node, fileSystemLayer], [FileSystemSearch.node, fileSystemSearchLayer], [Pty.node, ptyLayer], @@ -102,22 +101,6 @@ const vcsLayer = Layer.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( diff --git a/packages/workerd-spike/test/spike.test.ts b/packages/workerd-spike/test/spike.test.ts index f9f5d25d470..91f3513b105 100644 --- a/packages/workerd-spike/test/spike.test.ts +++ b/packages/workerd-spike/test/spike.test.ts @@ -184,6 +184,25 @@ it("runs a full prompt turn against a fake provider and reads the durable log", expect(eventTypes).toContain("log.synced") }) +it("fails a shell command through the no-execution-plane spawner and leaves the session usable", async () => { + const sessionID = await createSession() + const shell = await request(`/api/session/${sessionID}/shell`, { + method: "POST", + body: JSON.stringify({ command: "pwd" }), + }) + expect(shell.status).toBe(500) + + mockLLM() + const prompt = await request(`/api/session/${sessionID}/prompt`, { + method: "POST", + body: JSON.stringify({ text: "Say hello after the shell failure" }), + }) + expect(prompt.status).toBe(200) + const wait = await request(`/api/session/${sessionID}/wait`, { method: "POST" }) + expect(wait.status).toBe(204) + expect((await readLog(sessionID)).map((item) => item.type)).toContain("session.execution.succeeded") +}) + // A5 question 1 (ack-then-continue): the Slack flow returns the prompt request // immediately and lets the turn continue inside the DO with no request held // open. The turn runs on the coordinator's background fiber in the app layer