Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/core/src/environment/unavailable.ts
Original file line numberDiff line numberDiff line change
@@ -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"
7 changes: 0 additions & 7 deletions packages/core/src/mcp/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand DownExpand Up@@ -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" }
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/session.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -640,7 +640,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,
Expand Down
29 changes: 17 additions & 12 deletions packages/core/src/shell.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand DownExpand Up@@ -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>
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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)
Expand Down
16 changes: 15 additions & 1 deletion packages/core/test/environment.test.ts
Original file line numberDiff line numberDiff line change
@@ -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,
Expand DownExpand Up@@ -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()
Expand Down
22 changes: 22 additions & 0 deletions packages/core/test/mcp.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand DownExpand Up@@ -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(
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/handlers/shell.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(
Expand Down
6 changes: 0 additions & 6 deletions packages/server/src/options.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
1 change: 0 additions & 1 deletion packages/server/src/routes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)],
Expand Down
31 changes: 7 additions & 24 deletions packages/server/src/workerd.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand All@@ -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`
Expand DownExpand Up@@ -67,19 +69,16 @@ 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 },
}
}

/** The workerd replacement graph, applied after the standard server replacements. */
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],
Expand All@@ -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(
Expand Down
19 changes: 19 additions & 0 deletions packages/workerd-spike/test/spike.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading