spawner
This commit is contained in:
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 { SimulationNetwork } from "@/testing/simulation/network"
|
||||||
import { SimulationNetworkRoutes } from "@/testing/simulation/network-routes"
|
import { SimulationNetworkRoutes } from "@/testing/simulation/network-routes"
|
||||||
import { SimulationProvider } from "@/testing/simulation/provider"
|
import { SimulationProvider } from "@/testing/simulation/provider"
|
||||||
|
import { SimulationSpawner } from "@/testing/simulation/spawner"
|
||||||
import { Simulation } from "@/testing/simulation/service"
|
import { Simulation } from "@/testing/simulation/service"
|
||||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||||
import { serveUIEffect } from "@/server/shared/ui"
|
import { serveUIEffect } from "@/server/shared/ui"
|
||||||
import { ServerAuth } from "@/server/auth"
|
import { ServerAuth } from "@/server/auth"
|
||||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||||
|
import { InMemoryFs } from "just-bash"
|
||||||
import { PublicApi } from "./public"
|
import { PublicApi } from "./public"
|
||||||
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||||
import { EventApi } from "./groups/event"
|
import { EventApi } from "./groups/event"
|
||||||
|
|
@ -194,6 +196,27 @@ const uiRoute = HttpRouter.use((router) =>
|
||||||
}),
|
}),
|
||||||
).pipe(Layer.provide(authOnlyRouterLayer))
|
).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 =
|
type RouteRequirements =
|
||||||
| HttpRouter.HttpRouter
|
| HttpRouter.HttpRouter
|
||||||
| HttpRouter.Request<"Error", unknown>
|
| HttpRouter.Request<"Error", unknown>
|
||||||
|
|
@ -263,12 +286,21 @@ function createProductionRoutes(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typeof createProductionRoutes> {
|
export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typeof createProductionRoutes> {
|
||||||
|
const fs = new InMemoryFs()
|
||||||
const simulationBoundary = Layer.mergeAll(
|
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 }),
|
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(
|
const withRouteAndLeafServices = simulatedRoutes.pipe(
|
||||||
Layer.provideMerge(errorLayer),
|
Layer.provideMerge(errorLayer),
|
||||||
|
|
@ -288,7 +320,7 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typ
|
||||||
Layer.provideMerge(Pty.layer),
|
Layer.provideMerge(Pty.layer),
|
||||||
Layer.provideMerge(PtyTicket.layer),
|
Layer.provideMerge(PtyTicket.layer),
|
||||||
Layer.provideMerge(SessionShare.layer),
|
Layer.provideMerge(SessionShare.layer),
|
||||||
Layer.provideMerge(ShareNext.layer),
|
Layer.provideMerge(simulationShareNextLayer),
|
||||||
Layer.provideMerge(Workspace.layer),
|
Layer.provideMerge(Workspace.layer),
|
||||||
Layer.provideMerge(Worktree.layer),
|
Layer.provideMerge(Worktree.layer),
|
||||||
Layer.provideMerge(HttpServer.layerServices),
|
Layer.provideMerge(HttpServer.layerServices),
|
||||||
|
|
@ -349,7 +381,6 @@ export function createSimulatedRoutes(corsOptions?: CorsOptions): ReturnType<typ
|
||||||
Layer.provideMerge(Env.layer),
|
Layer.provideMerge(Env.layer),
|
||||||
Layer.provideMerge(Bus.layer),
|
Layer.provideMerge(Bus.layer),
|
||||||
Layer.provideMerge(Global.layer),
|
Layer.provideMerge(Global.layer),
|
||||||
Layer.provideMerge(CrossSpawnSpawner.layer),
|
|
||||||
Layer.provideMerge(NodePath.layer),
|
Layer.provideMerge(NodePath.layer),
|
||||||
Layer.provideMerge(simulationBoundary),
|
Layer.provideMerge(simulationBoundary),
|
||||||
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
|
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
|
||||||
|
|
|
||||||
94
packages/opencode/src/testing/simulation/spawner.ts
Normal file
94
packages/opencode/src/testing/simulation/spawner.ts
Normal file
|
|
@ -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"
|
||||||
60
packages/opencode/test/testing/simulation/spawner.test.ts
Normal file
60
packages/opencode/test/testing/simulation/spawner.test.ts
Normal file
|
|
@ -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")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue