spawner
This commit is contained in:
@@ -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")
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user