diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index f92e496b1..1d9cc41b8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -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 { + 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 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 => + Layer.succeed(ChildProcessSpawner)(make(options)) + +export * as SimulationSpawner from "./spawner" diff --git a/packages/opencode/test/testing/simulation/spawner.test.ts b/packages/opencode/test/testing/simulation/spawner.test.ts new file mode 100644 index 000000000..ca5d91f23 --- /dev/null +++ b/packages/opencode/test/testing/simulation/spawner.test.ts @@ -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") + }), + ) +})