feat(server): typed no-execution-plane environment for workerd profile (#42113)

This commit is contained in:
Kit Langton 2026-08-12 15:28:07 -04:00 committed by GitHub
parent a7642737c7
commit 2025ed939a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 103 additions and 53 deletions

View file

@ -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"

View file

@ -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" }

View file

@ -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,

View file

@ -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: <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E, R>
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@ -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<Active>()
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
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)

View file

@ -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()

View file

@ -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(

View file

@ -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(

View file

@ -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

View file

@ -120,7 +120,6 @@ function makeRoutes<AuthError, AuthServices>(
name: options.app?.name ?? "opencode",
version: options.app?.version ?? "unknown",
},
stdio: options.mcp?.stdio,
}),
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],

View file

@ -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(

View file

@ -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