This commit is contained in:
James Long
2026-05-17 14:57:03 -04:00
parent f2733330f7
commit 63abe037d8
3 changed files with 189 additions and 4 deletions
@@ -74,11 +74,13 @@ import { SimulationFileSystem } from "@/testing/simulation/filesystem"
import { SimulationNetwork } from "@/testing/simulation/network"
import { SimulationNetworkRoutes } from "@/testing/simulation/network-routes"
import { SimulationProvider } from "@/testing/simulation/provider"
import { SimulationSpawner } from "@/testing/simulation/spawner"
import { Simulation } from "@/testing/simulation/service"
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { InMemoryFs } from "just-bash"
import { PublicApi } from "./public"
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { EventApi } from "./groups/event"
@@ -194,6 +196,27 @@ const uiRoute = HttpRouter.use((router) =>
}),
).pipe(Layer.provide(authOnlyRouterLayer))
const simulationShareNextLayer = Layer.succeed(
ShareNext.Service,
ShareNext.Service.of({
init: () => Effect.void,
url: () => Effect.succeed("https://opncd.ai"),
request: () =>
Effect.succeed({
headers: {},
baseUrl: "https://opncd.ai",
api: {
create: "/api/shares",
sync: (shareID) => `/api/shares/${shareID}/sync`,
remove: (shareID) => `/api/shares/${shareID}`,
data: (shareID) => `/api/shares/${shareID}/data`,
},
}),
create: () => Effect.succeed({ id: "", url: "", secret: "" }),
remove: () => Effect.void,
}),
)
type RouteRequirements =
| HttpRouter.HttpRouter
| HttpRouter.Request<"Error", unknown>
@@ -263,12 +286,21 @@ function createProductionRoutes(
}
export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typeof createProductionRoutes> {
const fs = new InMemoryFs()
const simulationBoundary = Layer.mergeAll(
SimulationFileSystem.layer({ root: "/opencode" }),
SimulationFileSystem.layer({ root: "/opencode", fs }),
SimulationSpawner.layer({ root: "/opencode", fs }),
SimulationNetwork.layer({ entries: SimulationNetworkRoutes.defaults(), allowLoopback: true }),
)
const simulatedRoutes = Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute, simulationRoute)
const simulatedRoutes = Layer.mergeAll(
rootApiRoutes,
eventApiRoutes,
instanceRoutes,
docRoute,
uiRoute,
simulationRoute,
)
const withRouteAndLeafServices = simulatedRoutes.pipe(
Layer.provideMerge(errorLayer),
@@ -288,7 +320,7 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typ
Layer.provideMerge(Pty.layer),
Layer.provideMerge(PtyTicket.layer),
Layer.provideMerge(SessionShare.layer),
Layer.provideMerge(ShareNext.layer),
Layer.provideMerge(simulationShareNextLayer),
Layer.provideMerge(Workspace.layer),
Layer.provideMerge(Worktree.layer),
Layer.provideMerge(HttpServer.layerServices),
@@ -349,7 +381,6 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typ
Layer.provideMerge(Env.layer),
Layer.provideMerge(Bus.layer),
Layer.provideMerge(Global.layer),
Layer.provideMerge(CrossSpawnSpawner.layer),
Layer.provideMerge(NodePath.layer),
Layer.provideMerge(simulationBoundary),
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
@@ -0,0 +1,94 @@
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Shell } from "@/shell/shell"
import { Effect, Layer, Sink, Stream } from "effect"
import * as PlatformError from "effect/PlatformError"
import { ChildProcess } from "effect/unstable/process"
import {
ChildProcessSpawner,
ExitCode,
make as makeSpawner,
makeHandle,
ProcessId,
} from "effect/unstable/process/ChildProcessSpawner"
import { Bash, type IFileSystem } from "just-bash"
import path from "path"
export interface Options {
readonly fs: IFileSystem
readonly root: string
}
const encoder = new TextEncoder()
const shellNames = new Set(["bash", "dash", "ksh", "sh", "zsh"])
function commandText(command: ChildProcess.StandardCommand) {
if (command.options.shell) return command.command
const index = command.args.findIndex((arg) => arg === "-c" || arg === "-lc")
if (index >= 0) return command.args[index + 1]
}
function isShell(command: ChildProcess.StandardCommand) {
return Boolean(command.options.shell) || shellNames.has(Shell.name(command.command))
}
function error(method: string, command: ChildProcess.Command, description: string) {
return PlatformError.systemError({
_tag: "PermissionDenied",
module: "SimulationSpawner",
method,
description,
pathOrDescriptor: command._tag === "StandardCommand" ? [command.command, ...command.args].join(" ") : "pipeline",
})
}
function output(value: string) {
if (!value) return Stream.empty
return Stream.make(encoder.encode(value))
}
function cwd(options: Options, command: ChildProcess.StandardCommand) {
const root = path.resolve(options.root)
const resolved = path.resolve(root, command.options.cwd ?? root)
if (resolved === root || AppFileSystem.contains(root, resolved)) return Effect.succeed(resolved)
return Effect.fail(error("spawn", command, "Working directory is outside the simulated filesystem root"))
}
export function make(options: Options) {
const spawn = Effect.fn("SimulationSpawner.spawn")(function* (command: ChildProcess.Command) {
if (command._tag !== "StandardCommand") return yield* error("spawn", command, "Piped commands are not supported")
if (!isShell(command)) return yield* error("spawn", command, "Only shell commands are supported in simulation")
const text = commandText(command)
if (!text) return yield* error("spawn", command, "Shell command did not include command text")
const workingDirectory = yield* cwd(options, command)
const result = yield* Effect.promise(() =>
new Bash({ fs: options.fs, cwd: workingDirectory }).exec(text, {
env: Object.fromEntries(Object.entries(command.options.env ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
}),
)
const stdout = output(result.stdout)
const stderr = output(result.stderr)
return makeHandle({
pid: ProcessId(0),
stdin: Sink.drain,
stdout,
stderr,
all: output(result.stdout + result.stderr),
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
isRunning: Effect.succeed(false),
exitCode: Effect.succeed(ExitCode(result.exitCode)),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
})
})
return makeSpawner(spawn)
}
export const layer = (options: Options): Layer.Layer<ChildProcessSpawner> =>
Layer.succeed(ChildProcessSpawner)(make(options))
export * as SimulationSpawner from "./spawner"
@@ -0,0 +1,60 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { InMemoryFs } from "just-bash"
import { SimulationFileSystem } from "../../../src/testing/simulation/filesystem"
import { SimulationSpawner } from "../../../src/testing/simulation/spawner"
import { testEffect } from "../../lib/effect"
const root = "/opencode"
const fs = new InMemoryFs()
const it = testEffect(
Layer.mergeAll(SimulationFileSystem.layer({ root, fs }), SimulationSpawner.layer({ root, fs })),
)
describe("SimulationSpawner", () => {
it.effect("runs shell commands through just-bash", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const handle = yield* spawner.spawn(
ChildProcess.make("printf 'hello' > file.txt && cat file.txt", [], { cwd: root, shell: "/bin/bash" }),
).pipe(Effect.scoped)
expect(yield* Stream.mkString(Stream.decodeText(handle.stdout))).toBe("hello")
expect(Number(yield* handle.exitCode)).toBe(0)
expect(yield* Effect.promise(() => fs.readFile("/opencode/file.txt"))).toBe("hello")
}),
)
it.effect("extracts shell command text from -lc", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const handle = yield* spawner.spawn(ChildProcess.make("bash", ["-lc", "printf ok"], { cwd: root })).pipe(Effect.scoped)
expect(yield* Stream.mkString(Stream.decodeText(handle.stdout))).toBe("ok")
expect(Number(yield* handle.exitCode)).toBe(0)
}),
)
it.effect("rejects non-shell commands", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const exit = yield* spawner.spawn(ChildProcess.make("git", ["status"], { cwd: root })).pipe(Effect.scoped, Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
it.effect("rejects shell commands outside the simulation root", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const exit = yield* spawner.spawn(ChildProcess.make("printf blocked", [], { cwd: "/tmp", shell: "/bin/bash" })).pipe(
Effect.scoped,
Effect.exit,
)
expect(exit._tag).toBe("Failure")
}),
)
})