mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 00:23:24 +00:00
feat(server): typed no-execution-plane environment for workerd profile (#42113)
This commit is contained in:
parent
a7642737c7
commit
2025ed939a
11 changed files with 103 additions and 53 deletions
17
packages/core/src/environment/unavailable.ts
Normal file
17
packages/core/src/environment/unavailable.ts
Normal 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"
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue