From 48985233519d6ebad1ab472ffbe7eef72aec4fe0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 10 Aug 2026 22:57:54 +0200 Subject: [PATCH] feat(workspaces): introduce runtime execution boundary Establish the pipe-mode vertical slice before migrating terminals, files, Git, and providers. Runtime selection is immutable per workspace, and external runtimes remain unavailable to path-based daemon surfaces until those capabilities move behind the boundary. --- .github/workflows/docker.yml | 24 + package-lock.json | 18 +- package.json | 3 +- packages/docker-workspace-runtime/Dockerfile | 9 + .../docker-workspace-runtime/package.json | 17 + .../docker-workspace-runtime/src/index.ts | 498 ++++++++++++++++++ .../docker-workspace-runtime/src/workload.mjs | 46 ++ .../docker-workspace-runtime/tsconfig.json | 12 + packages/server/package.json | 1 + packages/server/src/server/bootstrap.ts | 31 ++ packages/server/src/server/config.test.ts | 84 ++- packages/server/src/server/config.ts | 1 + ...space-runtime-characterization.e2e.test.ts | 12 + .../src/server/persisted-config.test.ts | 27 + .../server/src/server/persisted-config.ts | 15 + packages/server/src/server/session.ts | 26 +- .../src/server/session.workspaces.test.ts | 44 +- .../workspace-provisioning-service.ts | 5 +- .../workspace-runtime-command-fixture.mjs | 213 ++++++++ .../server/src/server/websocket-server.ts | 8 + .../src/server/workspace-registry.test.ts | 22 + .../server/src/server/workspace-registry.ts | 9 + .../server/workspace-runtime/command/index.ts | 14 + .../command/internal/command-runtime.ts | 195 +++++++ .../command/internal/protocol.ts | 35 ++ .../server/workspace-runtime/drivers/index.ts | 80 +++ .../src/server/workspace-runtime/index.ts | 112 ++++ .../internal/host-process.ts | 59 +++ .../internal/local-runtime.ts | 85 +++ .../internal/runtime-state.ts | 48 ++ .../workspace-runtime/internal/service.ts | 208 ++++++++ .../internal/worktree-runtime.ts | 150 ++++++ .../workspace-runtime.command.test.ts | 153 ++++++ .../workspace-runtime.docker.e2e.test.ts | 405 ++++++++++++++ .../workspace-runtime.posix.test.ts | 372 +++++++++++++ .../public/schemas/paseo.config.v1.json | 26 + 36 files changed, 3054 insertions(+), 13 deletions(-) create mode 100644 packages/docker-workspace-runtime/Dockerfile create mode 100644 packages/docker-workspace-runtime/package.json create mode 100644 packages/docker-workspace-runtime/src/index.ts create mode 100644 packages/docker-workspace-runtime/src/workload.mjs create mode 100644 packages/docker-workspace-runtime/tsconfig.json create mode 100644 packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs create mode 100644 packages/server/src/server/workspace-runtime/command/index.ts create mode 100644 packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts create mode 100644 packages/server/src/server/workspace-runtime/command/internal/protocol.ts create mode 100644 packages/server/src/server/workspace-runtime/drivers/index.ts create mode 100644 packages/server/src/server/workspace-runtime/index.ts create mode 100644 packages/server/src/server/workspace-runtime/internal/host-process.ts create mode 100644 packages/server/src/server/workspace-runtime/internal/local-runtime.ts create mode 100644 packages/server/src/server/workspace-runtime/internal/runtime-state.ts create mode 100644 packages/server/src/server/workspace-runtime/internal/service.ts create mode 100644 packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts create mode 100644 packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts create mode 100644 packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts create mode 100644 packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d7a5bc09cc..efddeba1e6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -16,6 +16,7 @@ on: - "packages/app/**" - "packages/cli/**" - "packages/client/**" + - "packages/docker-workspace-runtime/**" - "packages/expo-two-way-audio/**" - "packages/highlight/**" - "packages/protocol/**" @@ -155,6 +156,29 @@ jobs: cache-from: type=gha,scope=paseo cache-to: type=gha,scope=paseo,mode=max + workspace-runtime-test: + needs: setup + if: needs.setup.outputs.publish != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: node scripts/npm-retry.mjs ci + + - name: Build server dependencies + run: npm run build:server-deps + + - name: Test workspace runtime contract + run: npm run test:workspace-runtime:docker --workspace=@getpaseo/server + publish: needs: setup if: needs.setup.outputs.publish == 'true' diff --git a/package-lock.json b/package-lock.json index 2147d093a2..787019f1fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "packages/relay", "packages/website", "packages/desktop", - "packages/cli" + "packages/cli", + "packages/docker-workspace-runtime" ], "devDependencies": { "@types/ws": "^8.5.14", @@ -6234,6 +6235,10 @@ "resolved": "packages/desktop", "link": true }, + "node_modules/@getpaseo/docker-workspace-runtime": { + "resolved": "packages/docker-workspace-runtime", + "link": true + }, "node_modules/@getpaseo/expo-two-way-audio": { "resolved": "packages/expo-two-way-audio", "link": true @@ -36644,6 +36649,17 @@ } } }, + "packages/docker-workspace-runtime": { + "name": "@getpaseo/docker-workspace-runtime", + "version": "0.3.1", + "bin": { + "paseo-docker-workspace-runtime": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "typescript": "^5.9.3" + } + }, "packages/expo-two-way-audio": { "name": "@getpaseo/expo-two-way-audio", "version": "0.3.1", diff --git a/package.json b/package.json index b16ae39975..0378efe487 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "packages/relay", "packages/website", "packages/desktop", - "packages/cli" + "packages/cli", + "packages/docker-workspace-runtime" ], "scripts": { "dev": "npm run dev:server", diff --git a/packages/docker-workspace-runtime/Dockerfile b/packages/docker-workspace-runtime/Dockerfile new file mode 100644 index 0000000000..a20c804114 --- /dev/null +++ b/packages/docker-workspace-runtime/Dockerfile @@ -0,0 +1,9 @@ +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git procps \ + && rm -rf /var/lib/apt/lists/* + +COPY src/workload.mjs /opt/paseo-workspace-runtime/workload.mjs + +WORKDIR /workspace diff --git a/packages/docker-workspace-runtime/package.json b/packages/docker-workspace-runtime/package.json new file mode 100644 index 0000000000..d14dbeba3d --- /dev/null +++ b/packages/docker-workspace-runtime/package.json @@ -0,0 +1,17 @@ +{ + "name": "@getpaseo/docker-workspace-runtime", + "version": "0.3.1", + "private": true, + "bin": { + "paseo-docker-workspace-runtime": "./dist/index.js" + }, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsgo -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@types/node": "^20.9.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/docker-workspace-runtime/src/index.ts b/packages/docker-workspace-runtime/src/index.ts new file mode 100644 index 0000000000..8c2756463e --- /dev/null +++ b/packages/docker-workspace-runtime/src/index.ts @@ -0,0 +1,498 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { realpath } from "node:fs/promises"; +import { Socket } from "node:net"; +import path from "node:path"; + +interface RuntimeState { + workspaceId: string; + root: string; + revision: string; + executionDomainId: string; + lifecycle: "ready" | "paused"; +} + +interface LifecycleRequest { + protocolVersion: 1; + input?: { + workspaceId: string; + project: { + source: + | { kind: "host-directory"; path: string } + | { kind: "git"; url: string; revision: string; subdirectory?: string }; + }; + placement: { relativeCwd?: string }; + }; + options: { image?: unknown }; +} + +interface ExecRequest { + protocolVersion: 1; + argv: [string, ...string[]]; + cwd?: string; + env: Record; + purpose: + | { kind: "agent"; agentId: string; provider: string } + | { kind: "terminal"; terminalId: string } + | { kind: "git" } + | { kind: "provider-probe"; provider: string } + | { kind: "workspace-helper" } + | { kind: "workspace-script"; script: string } + | { kind: "setup" } + | { kind: "archive" }; + options: { image?: unknown }; + execId: string; +} + +const [operation] = process.argv.slice(2); +const requestedWorkspaceId = argument("--workspace-id"); + +try { + if (operation === "describe") { + writeJson({ protocolVersion: 1, modes: ["pipes"] }); + } else if (!requestedWorkspaceId) { + throw new Error("--workspace-id is required"); + } else if (operation === "exec") { + await execute(requestedWorkspaceId); + } else if (operation === "signal") { + await readStdin(); + await signalExec( + requestedWorkspaceId, + requireArgument("--exec-id"), + requireSignal(requireArgument("--signal")), + ); + } else { + const request = JSON.parse(await readStdin()) as LifecycleRequest; + assertProtocol(request.protocolVersion); + switch (operation) { + case "create": + writeJson({ + protocolVersion: 1, + type: "state", + state: await create(requestedWorkspaceId, request), + }); + break; + case "inspect": + writeJson({ + protocolVersion: 1, + type: "inspection", + inspection: await inspect(requestedWorkspaceId), + }); + break; + case "pause": + await pause(requestedWorkspaceId); + writeJson({ protocolVersion: 1, type: "ok" }); + break; + case "resume": + writeJson({ + protocolVersion: 1, + type: "state", + state: await resume(requestedWorkspaceId), + }); + break; + case "destroy": + await destroy(requestedWorkspaceId); + writeJson({ protocolVersion: 1, type: "ok" }); + break; + default: + throw new Error(`Unknown operation: ${operation}`); + } + } +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} + +async function create(workspaceId: string, request: LifecycleRequest): Promise { + const existing = await inspect(workspaceId); + if (existing.status === "ready" || existing.status === "paused") return existing.state; + const input = request.input; + if (!input) throw new Error("create input is required"); + const image = requireImage(request.options); + const relativeRoot = + input.project.source.kind === "git" + ? input.project.source.subdirectory + : input.placement.relativeCwd; + const requestedRoot = resolveWorkspaceCwd("/workspace", relativeRoot); + const names = resourceNames(workspaceId); + const existingVolume = await inspectVolume(names.volume); + if (existingVolume) { + if (existingVolume.owner !== workspaceId) { + throw new Error(`Docker volume ownership mismatch: ${names.volume}`); + } + throw new Error(`Docker workspace has an incomplete owned volume: ${workspaceId}`); + } + await docker([ + "volume", + "create", + "--label", + "sh.paseo.runtime=workspace", + "--label", + `sh.paseo.workspace-id=${workspaceId}`, + names.volume, + ]); + + try { + const source = input.project.source; + const cloneSource = source.kind === "host-directory" ? "/source" : source.url; + const revision = source.kind === "git" ? source.revision : ""; + const mounts = ["-v", `${names.volume}:/workspace`]; + if (source.kind === "host-directory") { + mounts.push("-v", `${await realpath(source.path)}:/source:ro`); + } + await docker([ + "run", + "--rm", + ...mounts, + image, + "/bin/sh", + "-c", + 'git clone --no-hardlinks "$1" /workspace && if [ -n "$2" ]; then git -C /workspace checkout --detach "$2"; fi', + "paseo-materialize", + cloneSource, + revision, + ]); + const commit = ( + await docker([ + "run", + "--rm", + "-v", + `${names.volume}:/workspace`, + image, + "git", + "-C", + "/workspace", + "rev-parse", + "HEAD", + ]) + ).trim(); + const root = ( + await docker([ + "run", + "--rm", + "-v", + `${names.volume}:/workspace`, + image, + "/bin/sh", + "-c", + 'cd "$1" && pwd -P', + "paseo-resolve-root", + requestedRoot, + ]) + ).trim(); + assertContainedPath("/workspace", root); + await docker([ + "create", + "--init", + "--name", + names.container, + "--label", + "sh.paseo.runtime=workspace", + "--label", + `sh.paseo.workspace-id=${workspaceId}`, + "--label", + `sh.paseo.workspace-root=${root}`, + "--label", + `sh.paseo.workspace-revision=${commit}`, + "-v", + `${names.volume}:/workspace`, + image, + "/bin/sh", + "-c", + "exec tail -f /dev/null", + ]); + await docker(["start", names.container]); + await waitUntilReady(names.container); + return stateFor(workspaceId, root, commit, names.container); + } catch (error) { + await docker(["rm", "-f", names.container], true); + await docker(["volume", "rm", "-f", names.volume], true); + throw error; + } +} + +async function inspect( + workspaceId: string, +): Promise<{ status: "missing" } | { status: "ready" | "paused"; state: RuntimeState }> { + const names = resourceNames(workspaceId); + const inspection = await docker( + [ + "inspect", + "--format", + '{{.State.Running}}|{{index .Config.Labels "sh.paseo.runtime"}}|{{index .Config.Labels "sh.paseo.workspace-id"}}|{{index .Config.Labels "sh.paseo.workspace-root"}}|{{index .Config.Labels "sh.paseo.workspace-revision"}}', + names.container, + ], + true, + ); + if (!inspection) return { status: "missing" }; + const [running, owner, inspectedWorkspaceId, root, revision] = inspection.trim().split("|"); + if (owner !== "workspace" || inspectedWorkspaceId !== workspaceId || !root || !revision) { + throw new Error(`Docker resource ownership mismatch: ${names.container}`); + } + return { + status: running === "true" ? "ready" : "paused", + state: stateFor( + workspaceId, + root, + revision, + names.container, + running === "true" ? "ready" : "paused", + ), + }; +} + +async function pause(workspaceId: string): Promise { + const current = await inspect(workspaceId); + if (current.status === "missing" || current.status === "paused") return; + await docker(["stop", "--time", "5", resourceNames(workspaceId).container]); +} + +async function resume(workspaceId: string): Promise { + const current = await inspect(workspaceId); + if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`); + if (current.status === "paused") await docker(["start", resourceNames(workspaceId).container]); + await waitUntilReady(resourceNames(workspaceId).container); + const ready = await inspect(workspaceId); + if (ready.status !== "ready") throw new Error(`Docker workspace did not resume: ${workspaceId}`); + return ready.state; +} + +async function destroy(workspaceId: string): Promise { + const names = resourceNames(workspaceId); + const current = await inspect(workspaceId); + const volume = await inspectVolume(names.volume); + if (volume && volume.owner !== workspaceId) { + throw new Error(`Docker volume ownership mismatch: ${names.volume}`); + } + if (current.status !== "missing") await docker(["rm", "-f", names.container]); + if (volume) await docker(["volume", "rm", names.volume]); +} + +async function inspectVolume(name: string): Promise<{ owner: string | null } | null> { + const output = await docker(["volume", "inspect", name], true); + if (!output) return null; + const inspection = JSON.parse(output) as Array<{ Labels?: Record | null }>; + return { owner: inspection[0]?.Labels?.["sh.paseo.workspace-id"] ?? null }; +} + +async function execute(workspaceId: string): Promise { + const request = JSON.parse(await readOneShotEnvelope()) as ExecRequest; + assertProtocol(request.protocolVersion); + if (!/^[a-f0-9]{32}$/.test(request.execId)) throw new Error("Invalid exec id"); + const current = await inspect(workspaceId); + if (current.status !== "ready") throw new Error(`Docker workspace is ${current.status}`); + const requestedCwd = resolveWorkspaceCwd(current.state.root, request.cwd); + const cwd = ( + await docker([ + "exec", + resourceNames(workspaceId).container, + "/bin/sh", + "-c", + 'cd "$1" && pwd -P', + "paseo-resolve-cwd", + requestedCwd, + ]) + ).trim(); + assertContainedPath(current.state.root, cwd); + const encoded = Buffer.from( + JSON.stringify({ argv: request.argv, cwd, env: request.env }), + ).toString("base64url"); + const execId = request.execId; + const container = resourceNames(workspaceId).container; + const child = spawn( + "docker", + [ + "exec", + "-i", + "-e", + "PASEO_RUNTIME_EXEC", + "-e", + "PASEO_RUNTIME_EXEC_ID", + container, + "node", + "/opt/paseo-workspace-runtime/workload.mjs", + ], + { + env: { ...process.env, PASEO_RUNTIME_EXEC: encoded, PASEO_RUNTIME_EXEC_ID: execId }, + stdio: ["inherit", "inherit", "inherit"], + }, + ); + const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }, + ); + let forwardedSignal: NodeJS.Signals | null = null; + const forwardSignal = async (signal: NodeJS.Signals): Promise => { + forwardedSignal = signal; + await signalExec(workspaceId, execId, signal); + }; + const hostSignalHandlers = new Map void>(); + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + const handler = () => void forwardSignal(signal).catch(() => child.kill(signal)); + hostSignalHandlers.set(signal, handler); + process.on(signal, handler); + } + const exit = await exitPromise; + for (const [signal, handler] of hostSignalHandlers) process.off(signal, handler); + if (forwardedSignal) { + process.removeAllListeners(forwardedSignal); + process.kill(process.pid, forwardedSignal); + return; + } + if (exit.signal) { + process.kill(process.pid, exit.signal); + return; + } + process.exitCode = exit.code ?? 1; +} + +async function signalExec( + workspaceId: string, + execId: string, + signal: NodeJS.Signals, +): Promise { + if (!/^[a-f0-9]{32}$/.test(execId)) throw new Error("Invalid exec id"); + const current = await inspect(workspaceId); + if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`); + await docker([ + "exec", + resourceNames(workspaceId).container, + "/bin/sh", + "-c", + 'test ! -f "$1" || { read -r supervisor workload < "$1"; signal=${2#SIG}; kill -"$signal" "-$workload" 2>/dev/null || kill -"$signal" "$workload" 2>/dev/null || true; kill -"$signal" "$supervisor" 2>/dev/null || true; }', + "paseo-signal", + `/tmp/paseo-runtime-exec-${execId}.pid`, + signal, + ]); +} + +function resolveWorkspaceCwd(root: string, relativeCwd?: string): string { + if (!root.startsWith("/workspace") || path.posix.relative("/workspace", root).startsWith("..")) { + throw new Error(`Invalid Docker workspace root: ${root}`); + } + if (!relativeCwd) return root; + if (path.posix.isAbsolute(relativeCwd) || relativeCwd.includes("\\")) { + throw new Error("Workspace cwd must be relative"); + } + const resolved = path.posix.resolve(root, relativeCwd); + const relative = path.posix.relative(root, resolved); + if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) { + throw new Error("Workspace cwd escapes its root"); + } + return resolved; +} + +function assertContainedPath(root: string, candidate: string): void { + const relative = path.posix.relative(root, candidate); + if ( + !candidate || + relative === ".." || + relative.startsWith("../") || + path.posix.isAbsolute(relative) + ) { + throw new Error("Workspace cwd escapes its root"); + } +} + +function requireImage(options: LifecycleRequest["options"]): string { + if (typeof options.image !== "string" || !options.image.trim()) { + throw new Error("Docker runtime option image is required"); + } + return options.image; +} + +function resourceNames(workspaceId: string): { container: string; volume: string } { + const key = createHash("sha256").update(workspaceId).digest("hex").slice(0, 20); + return { container: `paseo-ws-${key}`, volume: `paseo-ws-${key}` }; +} + +function stateFor( + workspaceId: string, + root: string, + revision: string, + container: string, + lifecycle: "ready" | "paused" = "ready", +): RuntimeState { + return { workspaceId, root, revision, executionDomainId: `docker:${container}`, lifecycle }; +} + +async function waitUntilReady(container: string): Promise { + let lastError = ""; + for (let attempt = 0; attempt < 50; attempt += 1) { + const output = await docker( + ["exec", container, "/bin/sh", "-c", "test -d /workspace && echo ready"], + true, + ); + if (output.trim() === "ready") return; + lastError = `Container is not ready: ${container}`; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(lastError); +} + +async function docker(args: string[], allowFailure = false): Promise { + const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] }); + const [stdout, stderr, exit] = await Promise.all([ + readStream(child.stdout), + readStream(child.stderr), + new Promise<{ code: number | null }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve({ code })); + }), + ]); + if (exit.code !== 0) { + if (allowFailure) return ""; + throw new Error(`docker ${args[0]} failed: ${stderr.trim()}`); + } + return stdout; +} + +async function readStdin(): Promise { + return readStream(process.stdin); +} + +async function readOneShotEnvelope(): Promise { + const stream = new Socket({ fd: 3, readable: true, writable: false }); + try { + return await readStream(stream); + } finally { + stream.destroy(); + } +} + +async function readStream(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function writeJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function assertProtocol(version: unknown): asserts version is 1 { + if (version !== 1) throw new Error(`Unsupported protocol version: ${String(version)}`); +} + +function argument(flag: string): string | undefined { + const index = process.argv.indexOf(flag, 2); + return index < 0 ? undefined : process.argv[index + 1]; +} + +function requireArgument(flag: string): string { + const value = argument(flag); + if (!value) throw new Error(`${flag} is required`); + return value; +} + +function requireSignal(value: string): NodeJS.Signals { + if (value !== "SIGINT" && value !== "SIGTERM" && value !== "SIGHUP" && value !== "SIGKILL") { + throw new Error(`Unsupported signal: ${value}`); + } + return value; +} diff --git a/packages/docker-workspace-runtime/src/workload.mjs b/packages/docker-workspace-runtime/src/workload.mjs new file mode 100644 index 0000000000..6694f29f21 --- /dev/null +++ b/packages/docker-workspace-runtime/src/workload.mjs @@ -0,0 +1,46 @@ +import { spawn } from "node:child_process"; +import { rmSync, writeFileSync } from "node:fs"; + +const encoded = process.env.PASEO_RUNTIME_EXEC; +if (!encoded) throw new Error("PASEO_RUNTIME_EXEC is required"); +const request = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")); +delete process.env.PASEO_RUNTIME_EXEC; +const execId = process.env.PASEO_RUNTIME_EXEC_ID; +delete process.env.PASEO_RUNTIME_EXEC_ID; +if (!execId || !/^[a-f0-9]+$/.test(execId)) throw new Error("PASEO_RUNTIME_EXEC_ID is required"); +const pidFile = `/tmp/paseo-runtime-exec-${execId}.pid`; + +const child = spawn(request.argv[0], request.argv.slice(1), { + cwd: request.cwd, + env: request.env, + detached: true, + stdio: ["inherit", "inherit", "inherit"], +}); +if (!child.pid) throw new Error("Runtime workload did not start"); +writeFileSync(pidFile, `${process.pid} ${child.pid}`); + +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => { + if (!child.pid) return; + try { + process.kill(-child.pid, signal); + } catch { + child.kill(signal); + } + }); +} + +child.once("error", (error) => { + rmSync(pidFile, { force: true }); + process.stderr.write(`${error.message}\n`); + process.exitCode = 127; +}); +child.once("exit", (code, signal) => { + rmSync(pidFile, { force: true }); + if (signal) { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + return; + } + process.exitCode = code ?? 1; +}); diff --git a/packages/docker-workspace-runtime/tsconfig.json b/packages/docker-workspace-runtime/tsconfig.json new file mode 100644 index 0000000000..e6da0cc9d7 --- /dev/null +++ b/packages/docker-workspace-runtime/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "incremental": false, + "noEmit": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/server/package.json b/packages/server/package.json index 31ced62034..f487a83eb6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -58,6 +58,7 @@ "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", "test:integration:local": "vitest run local.e2e.test.ts", + "test:workspace-runtime:docker": "vitest run --maxWorkers=1 src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts", "test:watch": "vitest", "test:ui": "vitest --ui", "test:e2e": "vitest run e2e.test.ts --maxWorkers=1 --exclude \"**/*.real.e2e.test.ts\" --exclude \"**/*.local.e2e.test.ts\"", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 18609aca51..486a4a6c43 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -142,8 +142,10 @@ import { WorkspaceReconciliationService } from "./workspace-reconciliation-servi import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry, + resolveSelectedWorkspaceRuntimeId, type WorkspaceArchiveContext, } from "./workspace-registry.js"; +import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; import { FileBackedChatService } from "./chat/chat-service.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { LoopService } from "./loop-service.js"; @@ -384,6 +386,16 @@ export interface PaseoDaemonConfig { daemonVersion?: string; desktopManaged?: boolean; worktreesRoot?: string; + workspaceRuntimes?: Readonly< + Record< + string, + { + type: "command"; + command: readonly [string, ...string[]]; + options?: Readonly>; + } + > + >; corsAllowedOrigins: string[]; allowedHosts?: HostnamesConfig; hostnames?: HostnamesConfig; @@ -798,6 +810,24 @@ export async function createPaseoDaemon( path.join(config.paseoHome, "projects", "workspaces.json"), logger, ); + const workspaceRuntime = createWorkspaceRuntimeService({ + paseoHome: config.paseoHome, + worktreesRoot: config.worktreesRoot, + externalRuntimes: config.workspaceRuntimes, + resolveRuntimeId: async (workspaceId) => { + const workspace = await workspaceRegistry?.get(workspaceId); + return workspace ? resolveSelectedWorkspaceRuntimeId(workspace) : null; + }, + persistRuntimeId: async (workspaceId, runtimeId) => { + const existing = await workspaceRegistry?.get(workspaceId); + if (existing?.runtime?.runtimeId === runtimeId) return; + const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ + ...workspace, + runtime: { runtimeId }, + })); + if (!updated) throw new Error(`Workspace not found: ${workspaceId}`); + }, + }); const chatService = new FileBackedChatService({ paseoHome: config.paseoHome, logger, @@ -1570,6 +1600,7 @@ export async function createPaseoDaemon( browserToolsBroker, hubRelationships, workspaceSetupRuntime, + workspaceRuntime, ); relayRuntime = createRelayRuntime({ config: { diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index f9d4929c6b..e92a60347d 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -1,10 +1,11 @@ -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; import { loadConfig, resolveBundledWebUiDistDir } from "./config.js"; +import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; const roots: string[] = []; @@ -26,6 +27,85 @@ describe("server config", () => { expect(standaloneConfig.desktopManaged).toBe(false); }); + test("loads trusted external workspace runtime registrations", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-runtime-")); + roots.push(paseoHome); + const configPath = path.join(paseoHome, "config.json"); + await writeFile( + configPath, + JSON.stringify({ + version: 1, + workspaceRuntimes: { + fixture: { + type: "command", + command: ["/trusted/runtime", "--mode", "fixture"], + options: { image: "fixture:test" }, + }, + }, + }), + ); + await chmod(configPath, 0o600); + + expect(loadConfig(paseoHome, { env: {} }).workspaceRuntimes).toEqual({ + fixture: { + type: "command", + command: ["/trusted/runtime", "--mode", "fixture"], + options: { image: "fixture:test" }, + }, + }); + }); + + test("composes a configured executable for runtimeId selection and rejects others", async () => { + const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-runtime-compose-")); + roots.push(paseoHome); + const source = path.join(paseoHome, "source"); + const stateDirectory = path.join(paseoHome, "runtime-state"); + await Promise.all([mkdir(source), mkdir(stateDirectory)]); + const fixtureExecutable = fileURLToPath( + new URL("./test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url), + ); + const configPath = path.join(paseoHome, "config.json"); + await writeFile( + configPath, + JSON.stringify({ + version: 1, + workspaceRuntimes: { + fixture: { + type: "command", + command: [process.execPath, fixtureExecutable], + options: { stateDirectory }, + }, + }, + }), + ); + await chmod(configPath, 0o600); + const config = loadConfig(paseoHome, { env: {} }); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome, + externalRuntimes: config.workspaceRuntimes, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + }); + const createInput = { + workspaceId: "configured-runtime", + runtimeId: "fixture", + project: { id: "fixture", source: { kind: "host-directory" as const, path: source } }, + placement: { kind: "existing" as const }, + }; + + await expect(service.create(createInput)).resolves.toEqual({ + workspaceId: "configured-runtime", + runtimeId: "fixture", + }); + await expect( + service.create({ ...createInput, workspaceId: "unknown-runtime", runtimeId: "unknown" }), + ).rejects.toThrow("Workspace runtime is not registered: unknown"); + await service.destroy("configured-runtime"); + }); + test("resolves bundled web UI path from source-tree modules", () => { const root = path.parse(process.cwd()).root; expect( diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 93ab9409a1..d35722b9a0 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -509,6 +509,7 @@ export function loadConfig( paseoHome, desktopManaged: env.PASEO_DESKTOP_MANAGED === "1", worktreesRoot: resolveWorktreesRoot(paseoHome, persisted), + workspaceRuntimes: persisted.workspaceRuntimes, corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), hostnames, trustedProxies, diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts index c990d4c94e..e172f71ed3 100644 --- a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -240,6 +240,12 @@ describe("current workspace runtime journeys", () => { test("local workspace uses the public daemon/client behavior", async () => { const workspace = await createCharacterizedWorkspace("local"); try { + const records = JSON.parse( + readFileSync(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>; + expect(records.find((record) => record.workspaceId === workspace.id)?.runtime).toEqual({ + runtimeId: "local", + }); await expectFileEditAndWatch(workspace); await expectGitObservation(workspace); await expectTerminalCommand(workspace); @@ -256,6 +262,12 @@ describe("current workspace runtime journeys", () => { test("owned worktree runs setup and uses the same public daemon/client behavior", async () => { const workspace = await createCharacterizedWorkspace("worktree"); try { + const records = JSON.parse( + readFileSync(path.join(daemon.paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>; + expect( + records.find((record) => record.workspaceId === workspace.id)?.runtime, + ).toBeUndefined(); await expect .poll(() => client.fetchWorkspaceSetupStatus(workspace.id), { timeout: 30_000 }) .toMatchObject({ snapshot: { status: "completed", error: null } }); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 140bc0f40e..9689f7a2cc 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -137,6 +137,33 @@ describe("PersistedConfigSchema worktrees config", () => { }); }); +describe("PersistedConfigSchema workspace runtime config", () => { + test("accepts trusted command registrations and rejects an empty executable", () => { + expect( + PersistedConfigSchema.parse({ + workspaceRuntimes: { + fixture: { + type: "command", + command: ["/trusted/runtime", "--fixture"], + options: { image: "fixture:test" }, + }, + }, + }).workspaceRuntimes, + ).toEqual({ + fixture: { + type: "command", + command: ["/trusted/runtime", "--fixture"], + options: { image: "fixture:test" }, + }, + }); + expect(() => + PersistedConfigSchema.parse({ + workspaceRuntimes: { invalid: { type: "command", command: [] } }, + }), + ).toThrow(); + }); +}); + describe("PersistedConfigSchema provider credentials", () => { test("accepts separate OpenAI STT and TTS credentials", () => { const parsed = PersistedConfigSchema.parse({ diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index e864450682..1dac79bf86 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -82,6 +82,20 @@ const WorktreesConfigSchema = z }) .strict(); +const WorkspaceRuntimesConfigSchema = z.record( + z.string().min(1), + z + .object({ + type: z.literal("command"), + command: z + .array(z.string().min(1)) + .min(1) + .transform((command) => command as [string, ...string[]]), + options: z.record(z.string(), z.unknown()).optional(), + }) + .strict(), +); + const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, { message: "Expected a bcrypt hash", }); @@ -304,6 +318,7 @@ export const PersistedConfigSchema = z providers: ProvidersSchema.optional(), worktrees: WorktreesConfigSchema.optional(), + workspaceRuntimes: WorkspaceRuntimesConfigSchema.optional(), agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f67e14b12a..24afed06de 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -142,6 +142,7 @@ import { type WorkspaceMutation, type WorkspaceRegistry, } from "./workspace-registry.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; import { wrapSpokenInput } from "./voice-config.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { @@ -449,6 +450,7 @@ export interface SessionOptions { agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceRuntime?: WorkspaceRuntimeService; filesystem?: SessionFileSystem; chatService: FileBackedChatService; scheduleService: ScheduleService; @@ -623,6 +625,7 @@ export class Session { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly workspaceRuntime: WorkspaceRuntimeService | undefined; private readonly filesystem: SessionFileSystem; private readonly github: ForgeService; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; @@ -703,6 +706,7 @@ export class Session { agentStorage, projectRegistry, workspaceRegistry, + workspaceRuntime, filesystem, chatService, scheduleService, @@ -770,6 +774,7 @@ export class Session { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry; this.workspaceRegistry = workspaceRegistry; + this.workspaceRuntime = workspaceRuntime; this.filesystem = filesystem ?? nodeSessionFileSystem; this.github = github ?? createGitHubService(); this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; @@ -5375,8 +5380,27 @@ export class Session { cwd, explicitTitle ?? promptTitle, request.source.projectId, - { expectsInitialAgent: Boolean(request.firstAgentContext) }, + { expectsInitialAgent: Boolean(request.firstAgentContext), runtimeId: "local" }, ); + const workspaceRuntime = this.workspaceRuntime; + if (!workspaceRuntime) { + await this.workspaceRegistry.remove(workspace.workspaceId); + throw new Error("Workspace runtime service was not composed"); + } + try { + await workspaceRuntime.create({ + workspaceId: workspace.workspaceId, + runtimeId: "local", + project: { + id: workspace.projectId, + source: { kind: "host-directory", path: cwd }, + }, + placement: { kind: "existing" }, + }); + } catch (error) { + await this.workspaceRegistry.remove(workspace.workspaceId); + throw error; + } await this.syncWorkspaceGitObserverForWorkspace(workspace); const descriptor = await this.describeWorkspaceRecord(workspace); this.emit({ diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index f43a63f1f3..8f0fe6c04a 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -18,6 +18,7 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { createTestLogger } from "../test-utils/test-logger.js"; import { Session } from "./session.js"; import type { SessionOptions } from "./session.js"; +import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; import type { AgentUpdatesService } from "./session/agent-updates/agent-updates-service.js"; import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { TerminalManager } from "../terminal/terminal-manager.js"; @@ -80,6 +81,13 @@ const REPO_CWD = path.resolve("/tmp/repo"); const UNREGISTERED_CWD = path.resolve("/tmp/unregistered"); const terminalManagers: TerminalManager[] = []; +const runtimeDirectories: string[] = []; + +function createRuntimeDirectory(): string { + const directory = mkdtempSync(path.join(tmpdir(), "paseo-session-runtime-")); + runtimeDirectories.push(directory); + return directory; +} function deferred(): { promise: Promise; resolve: (value: T) => void } { let resolve!: (value: T) => void; @@ -101,6 +109,9 @@ afterEach(async () => { } } await flushTerminalContributionWork(); + for (const directory of runtimeDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } }); interface SessionTestAccess { @@ -691,6 +702,23 @@ function createSessionForWorkspaceTests( remove: async () => {}, }, workspaceRegistry, + workspaceRuntime: createWorkspaceRuntimeService({ + paseoHome: options.paseoHome ?? "/tmp/paseo-test", + worktreesRoot: options.worktreesRoot, + resolveRuntimeId: async (workspaceId) => { + const workspace = await workspaceRegistry.get(workspaceId); + return workspace?.runtime?.runtimeId ?? null; + }, + persistRuntimeId: async (workspaceId, runtimeId) => { + const workspace = await workspaceRegistry.get(workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + if (workspace.runtime?.runtimeId === runtimeId) return; + await workspaceRegistry.upsert({ + ...workspace, + runtime: { runtimeId }, + }); + }, + }), filesystem: { isDirectory: async () => true }, chatService: asChatService(), scheduleService: asScheduleService(), @@ -9229,6 +9257,7 @@ test("checkout.rename_branch.request renames the branch without a denormalized b }); test("workspace.create.response persists the first prompt as the initial title", async () => { + const runtimeCwd = createRuntimeDirectory(); const emitted: SessionOutboundMessage[] = []; const workspaces = new Map>(); const session = createSessionForWorkspaceTests({ @@ -9250,7 +9279,7 @@ test("workspace.create.response persists the first prompt as the initial title", await session.handleMessage({ type: "workspace.create.request", requestId: "req-create-first-prompt", - source: { kind: "directory", path: REPO_CWD }, + source: { kind: "directory", path: runtimeCwd }, firstAgentContext: { prompt: "Add retries to the payments flow\nwith exponential backoff", }, @@ -9269,6 +9298,7 @@ test("workspace.create.response persists the first prompt as the initial title", }); test("workspace create emits through a matching workspace subscription", async () => { + const runtimeCwd = createRuntimeDirectory(); const emitted: SessionOutboundMessage[] = []; const workspaces = new Map>(); let mutationListener: ((mutation: WorkspaceMutation) => void | Promise) | null = null; @@ -9310,7 +9340,7 @@ test("workspace create emits through a matching workspace subscription", async ( await session.handleMessage({ type: "workspace.create.request", requestId: "req-create-match", - source: { kind: "directory", path: REPO_CWD }, + source: { kind: "directory", path: runtimeCwd }, firstAgentContext: { prompt: "Implement the requested change" }, }); @@ -9322,6 +9352,7 @@ test("workspace create emits through a matching workspace subscription", async ( }); test("workspace create stays out of a non-matching workspace subscription", async () => { + const runtimeCwd = createRuntimeDirectory(); const emitted: SessionOutboundMessage[] = []; const workspaces = new Map>(); const session = createSessionForWorkspaceTests({ @@ -9350,20 +9381,21 @@ test("workspace create stays out of a non-matching workspace subscription", asyn await session.handleMessage({ type: "workspace.create.request", requestId: "req-create-filtered", - source: { kind: "directory", path: REPO_CWD }, + source: { kind: "directory", path: runtimeCwd }, }); expect(filterByType(emitted, "workspace_update")).toEqual([]); }); test("workspace.create.request attaches a directory workspace to its explicit active project", async () => { + const runtimeCwd = createRuntimeDirectory(); const emitted: SessionOutboundMessage[] = []; const projects = new Map([ [ "prj_explicit", createPersistedProjectRecord({ projectId: "prj_explicit", - rootPath: path.join(REPO_CWD, "unrelated"), + rootPath: path.join(runtimeCwd, "unrelated"), kind: "non_git", displayName: "unrelated", createdAt: "2026-03-01T00:00:00.000Z", @@ -9384,7 +9416,7 @@ test("workspace.create.request attaches a directory workspace to its explicit ac await session.handleMessage({ type: "workspace.create.request", requestId: "req-explicit-project", - source: { kind: "directory", path: REPO_CWD, projectId: "prj_explicit" }, + source: { kind: "directory", path: runtimeCwd, projectId: "prj_explicit" }, }); const response = findByType(emitted, "workspace.create.response"); @@ -9396,7 +9428,7 @@ test("workspace.create.request attaches a directory workspace to its explicit ac const workspaceId = response?.payload.workspace?.id; expect(workspaceId).toEqual(expect.any(String)); expect(workspaces.get(workspaceId as string)).toMatchObject({ - cwd: REPO_CWD, + cwd: runtimeCwd, projectId: "prj_explicit", }); }); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 90b0c7c128..770d4d79e8 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -57,7 +57,7 @@ export interface WorkspaceProvisioningService { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; runtimeId?: string }, ): Promise; createWorkspaceForWorktree( input: CreateWorktreeWorkspaceInput, @@ -186,7 +186,7 @@ export function createWorkspaceProvisioningService(deps: { cwd: string, title?: string | null, projectId?: string, - context?: { expectsInitialAgent?: boolean }, + context?: { expectsInitialAgent?: boolean; runtimeId?: string }, ): Promise { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); @@ -200,6 +200,7 @@ export function createWorkspaceProvisioningService(deps: { projectId: project.projectId, ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), title: title?.trim() || null, + ...(context?.runtimeId ? { runtime: { runtimeId: context.runtimeId } } : {}), createdAt: timestamp, updatedAt: timestamp, }); diff --git a/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs b/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs new file mode 100644 index 0000000000..93c243f5df --- /dev/null +++ b/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs @@ -0,0 +1,213 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Socket } from "node:net"; + +const [operation] = process.argv.slice(2); +const workspaceId = argument("--workspace-id"); + +try { + if (operation === "describe") { + writeJson({ protocolVersion: 1, modes: ["pipes"] }); + } else if (!workspaceId) { + throw new Error("--workspace-id is required"); + } else if (operation === "exec") { + await execute(workspaceId); + } else if (operation === "signal") { + await signal(workspaceId, requireArgument("--exec-id"), requireArgument("--signal")); + } else { + const request = JSON.parse(await readStream(process.stdin)); + switch (operation) { + case "create": + writeJson({ protocolVersion: 1, type: "state", state: await create(workspaceId, request) }); + break; + case "inspect": + writeJson({ + protocolVersion: 1, + type: "inspection", + inspection: await inspect(workspaceId, request.options), + }); + break; + case "pause": + await setLifecycle(workspaceId, request.options, "paused"); + writeJson({ protocolVersion: 1, type: "ok" }); + break; + case "resume": + writeJson({ + protocolVersion: 1, + type: "state", + state: await setLifecycle(workspaceId, request.options, "ready"), + }); + break; + case "destroy": + await rm(stateFile(workspaceId, request.options), { force: true }); + writeJson({ protocolVersion: 1, type: "ok" }); + break; + default: + throw new Error(`Unknown operation: ${operation}`); + } + } +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} + +async function create(id, request) { + const existing = await readState(id, request.options); + if (existing) return existing; + const root = request.input.project.source.path; + const state = { + workspaceId: id, + root, + revision: "fixture:1", + executionDomainId: "fixture", + lifecycle: "ready", + }; + await mkdir(request.options.stateDirectory, { recursive: true }); + await writeFile(stateFile(id, request.options), JSON.stringify(state)); + return state; +} + +async function inspect(id, options) { + await applyInspectBarrier(options); + const state = await readState(id, options); + return state ? { status: state.lifecycle, state } : { status: "missing" }; +} + +async function setLifecycle(id, options, lifecycle) { + const state = await readState(id, options); + if (!state) throw new Error(`Fixture workspace is missing: ${id}`); + const updated = { ...state, lifecycle }; + await writeFile(stateFile(id, options), JSON.stringify(updated)); + return updated; +} + +async function execute(id) { + const envelope = JSON.parse(await readOneShotEnvelope()); + const state = await readState(id, envelope.options); + if (!state) throw new Error(`Fixture workspace is missing: ${id}`); + await writeFile( + path.join(state.root, ".runtime-launch.json"), + JSON.stringify({ argv: process.argv, purpose: envelope.purpose }), + ); + const child = spawn(envelope.argv[0], envelope.argv.slice(1), { + cwd: path.resolve(state.root, envelope.cwd ?? "."), + env: envelope.env, + detached: true, + stdio: ["inherit", "inherit", "inherit"], + }); + await writeFile(execFile(id, envelope.execId, envelope.options), String(child.pid)); + let forwardedSignal = null; + const handlers = new Map(); + for (const signalName of ["SIGINT", "SIGTERM", "SIGHUP"]) { + const handler = () => { + forwardedSignal = signalName; + killGroup(child.pid, signalName); + }; + handlers.set(signalName, handler); + process.on(signalName, handler); + } + const exit = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signalName) => resolve({ code, signal: signalName })); + }); + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + for (const [signalName, handler] of handlers) process.off(signalName, handler); + const signalName = forwardedSignal ?? exit.signal; + if (signalName) { + process.removeAllListeners(signalName); + process.kill(process.pid, signalName); + } else { + process.exitCode = exit.code ?? 1; + } +} + +async function signal(id, execId, signalName) { + const { options } = JSON.parse(await readStream(process.stdin)); + const pid = Number(await readFile(execFile(id, execId, options), "utf8")); + killGroup(pid, signalName); +} + +async function applyInspectBarrier(options) { + if (!options.inspectBarrierDirectory) return; + const next = path.join(options.inspectBarrierDirectory, "block-next-inspect"); + try { + await rm(next); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + await writeFile(path.join(options.inspectBarrierDirectory, "inspect-entered"), "entered"); + const release = path.join(options.inspectBarrierDirectory, "release-inspect"); + while (true) { + try { + await readFile(release); + return; + } catch (error) { + if (error.code !== "ENOENT") throw error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } +} + +async function readOneShotEnvelope() { + const stream = new Socket({ fd: 3, readable: true, writable: false }); + try { + return await readStream(stream); + } finally { + stream.destroy(); + } +} + +async function readState(id, options) { + try { + return JSON.parse(await readFile(stateFile(id, options), "utf8")); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +function stateFile(id, options) { + return path.join(options.stateDirectory, `${key(id)}.json`); +} + +function execFile(id, execId, options) { + return path.join(options.stateDirectory, `${key(id)}-${execId}.pid`); +} + +function key(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function killGroup(pid, signalName) { + try { + process.kill(-pid, signalName); + } catch { + process.kill(pid, signalName); + } +} + +function argument(flag) { + const index = process.argv.indexOf(flag, 2); + return index < 0 ? undefined : process.argv[index + 1]; +} + +function requireArgument(flag) { + const value = argument(flag); + if (!value) throw new Error(`${flag} is required`); + return value; +} + +async function readStream(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function writeJson(value) { + process.stdout.write(`${JSON.stringify(value)}\n`); +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index cb01f18dcb..a563a0eba4 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -46,6 +46,7 @@ import type { import type { GitCommandRuntimeMetricsSnapshot } from "../utils/git-command-runtime-metrics.js"; import { snapshotGitCommandRuntimeMetrics } from "../utils/run-git-command.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; import { deriveProjectSlug } from "./workspace-git-metadata.js"; import { PushTokenStore } from "./push/token-store.js"; import { createPushNotificationSender, type PushNotificationSender } from "./push/notifications.js"; @@ -537,6 +538,7 @@ export class VoiceAssistantWebSocketServer { private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly workspaceRuntime: WorkspaceRuntimeService | undefined; private readonly chatService: FileBackedChatService; private readonly loopService: LoopService; private readonly scheduleService: ScheduleService; @@ -634,6 +636,7 @@ export class VoiceAssistantWebSocketServer { browserToolsBroker?: BrowserToolsBroker | null, hubRelationships?: HubRelationshipManagement | null, workspaceSetupRuntime: WorkspaceSetupRuntime = new WorkspaceSetupRuntime(), + workspaceRuntime?: WorkspaceRuntimeService, ) { this.logger = logger.child({ module: "websocket-server" }); this.workspaceSetupRuntime = workspaceSetupRuntime; @@ -651,6 +654,7 @@ export class VoiceAssistantWebSocketServer { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); + this.workspaceRuntime = workspaceRuntime; const requiredServices = requireWebSocketServices({ chatService, loopService, @@ -1332,6 +1336,9 @@ export class VoiceAssistantWebSocketServer { } private createSocketSession(options: SocketSessionOptions): Session { + if (!this.workspaceRuntime) { + throw new Error("Workspace runtime service was not composed"); + } return new Session({ clientId: options.clientId, appVersion: options.appVersion, @@ -1359,6 +1366,7 @@ export class VoiceAssistantWebSocketServer { agentStorage: this.agentStorage, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, + workspaceRuntime: this.workspaceRuntime, chatService: this.chatService, loopService: this.loopService, scheduleService: this.scheduleService, diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 75d0097b0a..e95949276d 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -12,6 +12,7 @@ import { FileBackedWorkspaceRegistry, resolveWorkspaceDisplayName, resolveWorkspaceName, + resolveSelectedWorkspaceRuntimeId, } from "./workspace-registry.js"; describe("resolveWorkspaceName", () => { @@ -43,6 +44,27 @@ describe("resolveWorkspaceName", () => { }); }); +describe("workspace runtime selection compatibility", () => { + test.each(["local_checkout", "directory", "worktree"] as const)( + "keeps an existing %s record on its legacy path until explicit cutover", + (kind) => { + const existing = createPersistedWorkspaceRecord({ + workspaceId: `legacy-${kind}`, + projectId: "legacy-project", + cwd: "/tmp/legacy", + kind, + displayName: "legacy", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + expect(resolveSelectedWorkspaceRuntimeId(existing)).toBeNull(); + expect( + resolveSelectedWorkspaceRuntimeId({ ...existing, runtime: { runtimeId: "selected" } }), + ).toBe("selected"); + }, + ); +}); + describe("workspace registries", () => { let tmpDir: string; let projectRegistry: FileBackedProjectRegistry; diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 5233e77e85..8ed5c904bf 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -77,6 +77,7 @@ const PersistedWorkspaceRecordSchema = z.object({ .transform((value) => value ?? null), isPaseoOwnedWorktree: z.boolean().default(false), mainRepoRoot: z.string().nullable().default(null), + runtime: z.object({ runtimeId: z.string().min(1) }).optional(), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -97,6 +98,12 @@ const PersistedWorkspaceRecordSchema = z.object({ export type PersistedProjectRecord = z.infer; export type PersistedWorkspaceRecord = z.infer; +export function resolveSelectedWorkspaceRuntimeId( + workspace: PersistedWorkspaceRecord, +): string | null { + return workspace.runtime?.runtimeId ?? null; +} + export interface WorkspaceMutation { kind: "upsert" | "archive" | "remove"; workspaceId: string; @@ -551,6 +558,7 @@ export function createPersistedWorkspaceRecord(input: { baseBranch?: string | null; isPaseoOwnedWorktree?: boolean; mainRepoRoot?: string | null; + runtime?: { runtimeId: string }; createdAt: string; updatedAt: string; archivedAt?: string | null; @@ -565,6 +573,7 @@ export function createPersistedWorkspaceRecord(input: { baseBranch: input.baseBranch ?? null, isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false, mainRepoRoot: input.mainRepoRoot ?? null, + ...(input.runtime ? { runtime: input.runtime } : {}), archivedAt: input.archivedAt ?? null, autoArchivedChangeRequestUrl: input.autoArchivedChangeRequestUrl ?? null, pinnedAt: input.pinnedAt ?? null, diff --git a/packages/server/src/server/workspace-runtime/command/index.ts b/packages/server/src/server/workspace-runtime/command/index.ts new file mode 100644 index 0000000000..3ed44d4ed5 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/command/index.ts @@ -0,0 +1,14 @@ +import type { WorkspaceRuntimeDriver } from "../drivers/index.js"; +import { createCommandRuntime } from "./internal/command-runtime.js"; + +export interface CommandRuntimeAdapterConfig { + command: readonly [string, ...string[]]; + options?: Readonly>; +} + +export function createCommandRuntimeAdapter( + runtimeId: string, + config: CommandRuntimeAdapterConfig, +): WorkspaceRuntimeDriver { + return createCommandRuntime(runtimeId, config); +} diff --git a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts new file mode 100644 index 0000000000..841fed9ed9 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts @@ -0,0 +1,195 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import type { Readable, Writable } from "node:stream"; + +import type { + WorkspaceDriverCreateInput, + WorkspaceDriverInspection, + WorkspaceDriverSpawnInput, + WorkspacePipeProcess, + WorkspaceRuntimeDriver, +} from "../../drivers/index.js"; +import { + CommandRuntimeDescribeResponseSchema, + CommandRuntimeLifecycleResponseSchema, +} from "./protocol.js"; + +export interface CommandRuntimeConfig { + command: readonly [string, ...string[]]; + options?: Readonly>; +} + +export function createCommandRuntime( + runtimeId: string, + config: CommandRuntimeConfig, +): WorkspaceRuntimeDriver { + let described: Promise | null = null; + + function ensureCompatible(): Promise { + described ??= runCommand(["describe"], undefined).then((output) => { + const description = CommandRuntimeDescribeResponseSchema.parse(JSON.parse(output)); + if (!description.modes.includes("pipes")) { + throw new Error(`Workspace runtime ${runtimeId} does not support pipes`); + } + return undefined; + }); + return described; + } + + async function lifecycle( + operation: "create" | "inspect" | "pause" | "resume" | "destroy", + workspaceId: string, + input?: WorkspaceDriverCreateInput, + ) { + await ensureCompatible(); + const output = await runCommand( + [operation, "--workspace-id", workspaceId], + JSON.stringify({ protocolVersion: 1, input, options: config.options ?? {} }), + ); + return CommandRuntimeLifecycleResponseSchema.parse(JSON.parse(output)); + } + + return { + id: runtimeId, + async create(input) { + const response = await lifecycle("create", input.workspaceId, input); + if (response.type !== "state") throw new Error(`Invalid create response from ${runtimeId}`); + return response.state; + }, + async inspect(workspaceId): Promise { + const response = await lifecycle("inspect", workspaceId); + if (response.type !== "inspection") { + throw new Error(`Invalid inspect response from ${runtimeId}`); + } + return response.inspection as WorkspaceDriverInspection; + }, + async spawn(input) { + await ensureCompatible(); + return spawnCommandProcess(runtimeId, config.command, input, config.options ?? {}); + }, + async pause(workspaceId) { + const response = await lifecycle("pause", workspaceId); + if (response.type !== "ok") throw new Error(`Invalid pause response from ${runtimeId}`); + }, + async resume(workspaceId) { + const response = await lifecycle("resume", workspaceId); + if (response.type !== "state") throw new Error(`Invalid resume response from ${runtimeId}`); + return response.state; + }, + async destroy(workspaceId) { + const response = await lifecycle("destroy", workspaceId); + if (response.type !== "ok") throw new Error(`Invalid destroy response from ${runtimeId}`); + }, + }; + + async function runCommand(args: string[], stdin: string | undefined): Promise { + const child = spawn(config.command[0], [...config.command.slice(1), ...args], { + env: commandEnvironment(), + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }); + if (stdin === undefined) child.stdin.end(); + else child.stdin.end(`${stdin}\n`); + const [stdout, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => resolve({ code, signal })); + }), + ]); + if (exit.code !== 0) { + throw new Error( + `Workspace runtime ${runtimeId} ${args[0]} failed (${exit.code ?? exit.signal}): ${stderr.trim()}`, + ); + } + return stdout; + } +} + +function spawnCommandProcess( + runtimeId: string, + command: readonly [string, ...string[]], + input: WorkspaceDriverSpawnInput, + options: Readonly>, +): WorkspacePipeProcess { + const execId = randomBytes(16).toString("hex"); + const child = spawn( + command[0], + [...command.slice(1), "exec", "--workspace-id", input.workspaceId], + { + env: commandEnvironment(), + shell: false, + stdio: ["pipe", "pipe", "pipe", "pipe"], + }, + ); + const metadata = child.stdio[3] as Writable; + metadata.on("error", () => { + // The exec process owns the actionable spawn error through `exited`. + }); + metadata.end( + JSON.stringify({ + protocolVersion: 1, + argv: input.argv, + cwd: input.cwd, + env: input.env, + purpose: input.purpose, + options, + execId, + }), + ); + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once("error", (error) => { + reject(new Error(`Workspace runtime ${runtimeId} exec failed: ${error.message}`)); + }); + child.once("exit", (code, signal) => resolve({ code, signal })); + }, + ); + + function forceKill(): void { + const signalCommand = spawn( + command[0], + [ + ...command.slice(1), + "signal", + "--workspace-id", + input.workspaceId, + "--exec-id", + execId, + "--signal", + "SIGKILL", + ], + { env: commandEnvironment(), shell: false, stdio: ["pipe", "ignore", "ignore"] }, + ); + signalCommand.stdin.end(JSON.stringify({ protocolVersion: 1, options })); + signalCommand.once("close", () => child.kill("SIGKILL")); + signalCommand.once("error", () => child.kill("SIGKILL")); + } + + return { + kind: "pipes", + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exited, + kill(signal = "SIGTERM") { + if (child.exitCode !== null || child.signalCode !== null) return; + if (signal === "SIGKILL") forceKill(); + else child.kill(signal); + }, + }; +} + +async function collect(stream: Readable): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function commandEnvironment(): NodeJS.ProcessEnv { + const env = { ...process.env }; + if (env.FORCE_COLOR !== undefined) delete env.NO_COLOR; + return env; +} diff --git a/packages/server/src/server/workspace-runtime/command/internal/protocol.ts b/packages/server/src/server/workspace-runtime/command/internal/protocol.ts new file mode 100644 index 0000000000..4e07b6febc --- /dev/null +++ b/packages/server/src/server/workspace-runtime/command/internal/protocol.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; + +export const CommandRuntimeStateSchema = z.object({ + workspaceId: z.string(), + root: z.string(), + revision: z.string(), + executionDomainId: z.string(), + lifecycle: z.enum(["ready", "paused"]), +}); + +export const CommandRuntimeInspectionSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("missing") }), + z.object({ status: z.literal("paused"), state: CommandRuntimeStateSchema }), + z.object({ status: z.literal("ready"), state: CommandRuntimeStateSchema }), + z.object({ status: z.literal("error"), message: z.string() }), +]); + +export const CommandRuntimeLifecycleResponseSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("state"), + protocolVersion: z.literal(1), + state: CommandRuntimeStateSchema, + }), + z.object({ + type: z.literal("inspection"), + protocolVersion: z.literal(1), + inspection: CommandRuntimeInspectionSchema, + }), + z.object({ type: z.literal("ok"), protocolVersion: z.literal(1) }), +]); + +export const CommandRuntimeDescribeResponseSchema = z.object({ + protocolVersion: z.literal(1), + modes: z.array(z.enum(["pipes"])), +}); diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts new file mode 100644 index 0000000000..1292d4ee68 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -0,0 +1,80 @@ +import type { Readable, Writable } from "node:stream"; + +export type WorkspaceRuntimeId = string; +export type WorkspaceId = string; + +export type WorkspaceProjectSource = + | { kind: "host-directory"; path: string } + | { kind: "git"; url: string; revision: string; subdirectory?: string }; + +export type WorkspacePlacementIntent = + | { kind: "existing"; relativeCwd?: string } + | { + kind: "branch"; + branchName: string; + baseRef: string; + relativeCwd?: string; + worktreeSlug?: string; + } + | { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string }; + +export interface WorkspaceDriverCreateInput { + workspaceId: WorkspaceId; + project: { projectId: string; source: WorkspaceProjectSource }; + placement: WorkspacePlacementIntent; +} + +export interface WorkspaceDriverState { + workspaceId: WorkspaceId; + root: string; + revision: string; + executionDomainId: string; + lifecycle: "ready" | "paused"; +} + +export type WorkspaceDriverInspection = + | { status: "missing" } + | { status: "paused"; state: WorkspaceDriverState } + | { status: "ready"; state: WorkspaceDriverState } + | { status: "error"; message: string }; + +export interface WorkspaceDriverSpawnInput { + workspaceId: WorkspaceId; + cwd?: string; + argv: readonly [string, ...string[]]; + env: Readonly>; + purpose: + | { kind: "agent"; agentId: string; provider: string } + | { kind: "terminal"; terminalId: string } + | { kind: "git" } + | { kind: "provider-probe"; provider: string } + | { kind: "workspace-helper" } + | { kind: "workspace-script"; script: string } + | { kind: "setup" } + | { kind: "archive" }; + stdio: { kind: "pipes" }; +} + +export interface WorkspaceProcessExit { + code: number | null; + signal: NodeJS.Signals | null; +} + +export interface WorkspacePipeProcess { + kind: "pipes"; + stdin: Writable; + stdout: Readable; + stderr: Readable; + exited: Promise; + kill(signal?: NodeJS.Signals): void; +} + +export interface WorkspaceRuntimeDriver { + readonly id: WorkspaceRuntimeId; + create(input: WorkspaceDriverCreateInput): Promise; + inspect(workspaceId: WorkspaceId): Promise; + spawn(input: WorkspaceDriverSpawnInput): Promise; + pause(workspaceId: WorkspaceId): Promise; + resume(workspaceId: WorkspaceId): Promise; + destroy(workspaceId: WorkspaceId): Promise; +} diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts new file mode 100644 index 0000000000..02e4147477 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -0,0 +1,112 @@ +import type { Readable, Writable } from "node:stream"; + +import { createCommandRuntimeAdapter } from "./command/index.js"; +import { createLocalRuntime } from "./internal/local-runtime.js"; +import { createService } from "./internal/service.js"; +import { createWorktreeRuntime } from "./internal/worktree-runtime.js"; + +export type WorkspaceSource = + | { kind: "host-directory"; path: string } + | { kind: "git"; url: string; revision: string; subdirectory?: string }; + +export type WorkspacePlacement = + | { kind: "existing"; relativeCwd?: string } + | { + kind: "branch"; + branchName: string; + baseRef: string; + relativeCwd?: string; + worktreeSlug?: string; + } + | { kind: "checkout"; ref: string; relativeCwd?: string; worktreeSlug?: string }; + +export type WorkspaceProcessPurpose = + | { kind: "agent"; agentId: string; provider: string } + | { kind: "terminal"; terminalId: string } + | { kind: "git" } + | { kind: "provider-probe"; provider: string } + | { kind: "workspace-helper" } + | { kind: "workspace-script"; script: string } + | { kind: "setup" } + | { kind: "archive" }; + +export interface WorkspaceSetupCommand { + cwd?: string; + argv: readonly [string, ...string[]]; + env: Readonly>; +} + +export interface CreateWorkspaceInput { + workspaceId: string; + runtimeId: string; + project: { id: string; source: WorkspaceSource }; + placement: WorkspacePlacement; + setup?: readonly WorkspaceSetupCommand[]; +} + +export interface WorkspaceProcessInput extends WorkspaceSetupCommand { + workspaceId: string; + purpose: WorkspaceProcessPurpose; +} + +export interface WorkspaceProcessExit { + code: number | null; + signal: NodeJS.Signals | null; +} + +export interface WorkspaceProcess { + readonly stdin: Writable; + readonly stdout: Readable; + readonly stderr: Readable; + readonly exited: Promise; + kill(signal?: NodeJS.Signals): void; +} + +export interface WorkspaceRuntimeService { + create(input: CreateWorkspaceInput): Promise<{ workspaceId: string; runtimeId: string }>; + run(input: WorkspaceProcessInput): Promise; + pause(workspaceId: string): Promise; + resume(workspaceId: string): Promise; + destroy(workspaceId: string): Promise; +} + +export interface WorkspaceRuntimeRecordStore { + resolveRuntimeId(workspaceId: string): Promise; + persistRuntimeId(workspaceId: string, runtimeId: string): Promise; +} + +export interface ExternalWorkspaceRuntime { + type: "command"; + command: readonly [string, ...string[]]; + options?: Readonly>; +} + +export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore { + paseoHome: string; + worktreesRoot?: string; + externalRuntimes?: Readonly>; +} + +export function createWorkspaceRuntimeService( + options: WorkspaceRuntimeOptions, +): WorkspaceRuntimeService { + const externalDrivers = Object.entries(options.externalRuntimes ?? {}).map( + ([runtimeId, config]) => { + if (runtimeId === "local" || runtimeId === "worktree") { + throw new Error(`Workspace runtime id is reserved: ${runtimeId}`); + } + return createCommandRuntimeAdapter(runtimeId, config); + }, + ); + return createService( + [ + createLocalRuntime(options.paseoHome), + createWorktreeRuntime({ + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + }), + ...externalDrivers, + ], + options, + ); +} diff --git a/packages/server/src/server/workspace-runtime/internal/host-process.ts b/packages/server/src/server/workspace-runtime/internal/host-process.ts new file mode 100644 index 0000000000..9a3a485a2c --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/host-process.ts @@ -0,0 +1,59 @@ +import { spawn } from "node:child_process"; +import { realpath } from "node:fs/promises"; +import path from "node:path"; + +import type { + WorkspaceDriverSpawnInput, + WorkspacePipeProcess, + WorkspaceProcessExit, +} from "../drivers/index.js"; + +export async function spawnHostProcess( + root: string, + input: WorkspaceDriverSpawnInput, +): Promise { + const cwd = await resolveRuntimeCwd(root, input.cwd); + const child = spawn(input.argv[0], input.argv.slice(1), { + cwd, + env: { ...input.env }, + shell: false, + detached: process.platform !== "win32", + stdio: ["pipe", "pipe", "pipe"], + }); + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => { + resolve({ code, signal: signal as NodeJS.Signals | null }); + }); + }); + + return { + kind: "pipes", + stdin: child.stdin, + stdout: child.stdout, + stderr: child.stderr, + exited, + kill(signal = "SIGTERM") { + if (child.exitCode !== null || child.signalCode !== null) return; + if (process.platform !== "win32" && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // The process can exit between the state check and the signal. + } + } + child.kill(signal); + }, + }; +} + +export async function resolveRuntimeCwd(root: string, relativeCwd?: string): Promise { + const normalizedRoot = await realpath(root); + const cwd = await realpath(path.resolve(normalizedRoot, relativeCwd ?? ".")); + const relative = path.relative(normalizedRoot, cwd); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Workspace cwd escapes its runtime root: ${relativeCwd}`); + } + return cwd; +} diff --git a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts new file mode 100644 index 0000000000..78800b9849 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts @@ -0,0 +1,85 @@ +import { stat } from "node:fs/promises"; +import path from "node:path"; + +import type { + WorkspaceDriverCreateInput, + WorkspaceDriverInspection, + WorkspaceDriverSpawnInput, + WorkspaceDriverState, + WorkspaceRuntimeDriver, +} from "../drivers/index.js"; +import { resolveRuntimeCwd, spawnHostProcess } from "./host-process.js"; +import { createRuntimeStateStore } from "./runtime-state.js"; + +export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { + const states = createRuntimeStateStore(paseoHome, "local"); + + async function inspect(workspaceId: string): Promise { + const state = await states.read(workspaceId); + if (!state) return { status: "missing" }; + try { + if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; + return { status: state.lifecycle, state }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + return { status: "error", message: String(error) }; + } + } + + async function requireReady(workspaceId: string): Promise { + const inspection = await inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`); + } + return inspection.state; + } + + return { + id: "local", + async create(input: WorkspaceDriverCreateInput) { + const existing = await inspect(input.workspaceId); + if (existing.status === "ready" || existing.status === "paused") return existing.state; + if (input.project.source.kind !== "host-directory" || input.placement.kind !== "existing") { + throw new Error("The local runtime adopts an existing host directory"); + } + const root = await resolveRuntimeCwd( + path.resolve(input.project.source.path), + input.placement.relativeCwd, + ); + if (!(await stat(root)).isDirectory()) throw new Error(`Directory not found: ${root}`); + const state: WorkspaceDriverState = { + workspaceId: input.workspaceId, + root, + revision: "local:1", + executionDomainId: "host", + lifecycle: "ready", + }; + await states.write(state); + return state; + }, + inspect, + async spawn(input: WorkspaceDriverSpawnInput) { + return spawnHostProcess((await requireReady(input.workspaceId)).root, input); + }, + async pause(workspaceId) { + const inspection = await inspect(workspaceId); + if (inspection.status === "paused") return; + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`); + } + await states.write({ ...inspection.state, lifecycle: "paused" }); + }, + async resume(workspaceId) { + const inspection = await inspect(workspaceId); + if (inspection.status === "missing" || inspection.status === "error") { + throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`); + } + const state = { ...inspection.state, lifecycle: "ready" as const }; + await states.write(state); + return state; + }, + async destroy(workspaceId) { + await states.remove(workspaceId); + }, + }; +} diff --git a/packages/server/src/server/workspace-runtime/internal/runtime-state.ts b/packages/server/src/server/workspace-runtime/internal/runtime-state.ts new file mode 100644 index 0000000000..962f9b6437 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/runtime-state.ts @@ -0,0 +1,48 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import type { WorkspaceDriverState } from "../drivers/index.js"; + +export function createRuntimeStateStore(paseoHome: string, runtimeId: string) { + const directory = path.join(paseoHome, "workspace-runtimes", runtimeId); + const fileFor = (workspaceId: string) => + path.join(directory, `${createHash("sha256").update(workspaceId).digest("hex")}.json`); + return { + async read(workspaceId: string): Promise { + try { + const parsed = JSON.parse(await readFile(fileFor(workspaceId), "utf8")) as unknown; + if (!isWorkspaceDriverState(parsed, workspaceId)) return null; + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + }, + async write(state: WorkspaceDriverState): Promise { + await mkdir(directory, { recursive: true }); + const target = fileFor(state.workspaceId); + const temporary = `${target}.${process.pid}.tmp`; + await writeFile(temporary, `${JSON.stringify(state)}\n`, { mode: 0o600 }); + await rename(temporary, target); + }, + async remove(workspaceId: string): Promise { + await rm(fileFor(workspaceId), { force: true }); + }, + }; +} + +function isWorkspaceDriverState( + value: unknown, + workspaceId: string, +): value is WorkspaceDriverState { + if (!value || typeof value !== "object") return false; + const state = value as Partial; + return ( + state.workspaceId === workspaceId && + typeof state.root === "string" && + typeof state.revision === "string" && + typeof state.executionDomainId === "string" && + (state.lifecycle === "ready" || state.lifecycle === "paused") + ); +} diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts new file mode 100644 index 0000000000..2ed1514d2d --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -0,0 +1,208 @@ +import type { + CreateWorkspaceInput, + WorkspaceProcess, + WorkspaceProcessInput, + WorkspaceRuntimeRecordStore, + WorkspaceRuntimeService, +} from "../index.js"; +import type { + WorkspaceDriverCreateInput, + WorkspacePipeProcess, + WorkspaceRuntimeDriver, +} from "../drivers/index.js"; + +const gracefulStopMilliseconds = 1_000; +const forcedStopMilliseconds = 1_000; + +export function createService( + drivers: readonly WorkspaceRuntimeDriver[], + records: WorkspaceRuntimeRecordStore, +): WorkspaceRuntimeService { + const driversById = new Map(drivers.map((driver) => [driver.id, driver])); + const processesByWorkspaceId = new Map>(); + const workspaceTails = new Map>(); + + function requireRegistered(runtimeId: string): WorkspaceRuntimeDriver { + const driver = driversById.get(runtimeId); + if (!driver) throw new Error(`Workspace runtime is not registered: ${runtimeId}`); + return driver; + } + + async function resolve(workspaceId: string): Promise { + const runtimeId = await records.resolveRuntimeId(workspaceId); + if (!runtimeId) throw new Error(`Workspace runtime is not selected: ${workspaceId}`); + return requireRegistered(runtimeId); + } + + async function runWithDriver( + driver: WorkspaceRuntimeDriver, + input: WorkspaceProcessInput, + ): Promise { + validateRelativeCwd(input.cwd); + const inspection = await driver.inspect(input.workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime is ${inspection.status}: ${input.workspaceId}`); + } + const runtimeProcess = await driver.spawn({ ...input, stdio: { kind: "pipes" } }); + const processes = processesByWorkspaceId.get(input.workspaceId) ?? new Set(); + processes.add(runtimeProcess); + processesByWorkspaceId.set(input.workspaceId, processes); + void runtimeProcess.exited.then( + () => forgetProcess(input.workspaceId, runtimeProcess), + () => forgetProcess(input.workspaceId, runtimeProcess), + ); + return runtimeProcess; + } + + return { + async create(input) { + return sequence(input.workspaceId, async () => { + const selectedRuntimeId = await records.resolveRuntimeId(input.workspaceId); + if (selectedRuntimeId && selectedRuntimeId !== input.runtimeId) { + throw new Error( + `Workspace runtime is already selected as ${selectedRuntimeId}: ${input.workspaceId}`, + ); + } + const driver = requireRegistered(input.runtimeId); + const before = await driver.inspect(input.workspaceId); + let ownsNewResource = false; + try { + await driver.create(toDriverCreateInput(input)); + ownsNewResource = before.status === "missing"; + if (ownsNewResource) { + for (const command of input.setup ?? []) { + const process = await runWithDriver(driver, { + workspaceId: input.workspaceId, + ...command, + purpose: { kind: "setup" }, + }); + process.stdin.end(); + const [, , exit] = await Promise.all([ + drain(process.stdout), + drain(process.stderr), + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) { + throw new Error(`Workspace setup failed: ${exit.code ?? exit.signal}`); + } + } + } + await records.persistRuntimeId(input.workspaceId, input.runtimeId); + return { workspaceId: input.workspaceId, runtimeId: input.runtimeId }; + } catch (error) { + if (ownsNewResource) { + try { + await stopProcesses(input.workspaceId); + await driver.destroy(input.workspaceId); + } catch (cleanupError) { + throw new Error(`Workspace creation failed before cleanup: ${String(error)}`, { + cause: cleanupError, + }); + } + } + throw error; + } + }); + }, + async run(input) { + return sequence(input.workspaceId, async () => + runWithDriver(await resolve(input.workspaceId), input), + ); + }, + async pause(workspaceId) { + await sequence(workspaceId, async () => { + const driver = await resolve(workspaceId); + await stopProcesses(workspaceId); + await driver.pause(workspaceId); + }); + }, + async resume(workspaceId) { + await sequence(workspaceId, async () => { + await (await resolve(workspaceId)).resume(workspaceId); + }); + }, + async destroy(workspaceId) { + await sequence(workspaceId, async () => { + const driver = await resolve(workspaceId); + await stopProcesses(workspaceId); + await driver.destroy(workspaceId); + }); + }, + }; + + async function sequence(workspaceId: string, operation: () => Promise): Promise { + const previous = workspaceTails.get(workspaceId) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolveGate) => { + release = resolveGate; + }); + const tail = previous.then(() => gate); + workspaceTails.set(workspaceId, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (workspaceTails.get(workspaceId) === tail) workspaceTails.delete(workspaceId); + } + } + + function forgetProcess(workspaceId: string, runtimeProcess: WorkspacePipeProcess): void { + const processes = processesByWorkspaceId.get(workspaceId); + processes?.delete(runtimeProcess); + if (processes?.size === 0) processesByWorkspaceId.delete(workspaceId); + } + + async function stopProcesses(workspaceId: string): Promise { + const processes = [...(processesByWorkspaceId.get(workspaceId) ?? [])]; + for (const runtimeProcess of processes) runtimeProcess.kill("SIGTERM"); + const graceful = await waitForAll(processes, gracefulStopMilliseconds); + if (graceful) return; + for (const runtimeProcess of processes) runtimeProcess.kill("SIGKILL"); + if (!(await waitForAll(processes, forcedStopMilliseconds))) { + throw new Error(`Workspace processes did not stop: ${workspaceId}`); + } + } +} + +async function drain(stream: NodeJS.ReadableStream): Promise { + for await (const _chunk of stream) { + // Setup output is consumed here so a verbose command cannot block on a full pipe. + } +} + +function validateRelativeCwd(cwd: string | undefined): void { + if (cwd === undefined) return; + const parts = cwd.split(/[\\/]/u); + if (cwd.startsWith("/") || cwd.includes("\\") || parts.includes("..")) { + throw new Error(`Workspace cwd must stay within the runtime root: ${cwd}`); + } +} + +function toDriverCreateInput(input: CreateWorkspaceInput): WorkspaceDriverCreateInput { + return { + workspaceId: input.workspaceId, + project: { + projectId: input.project.id, + source: input.project.source, + }, + placement: input.placement, + }; +} + +async function waitForAll( + processes: readonly WorkspacePipeProcess[], + timeoutMilliseconds: number, +): Promise { + if (processes.length === 0) return true; + let timer: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMilliseconds); + }); + const exited = Promise.allSettled(processes.map((runtimeProcess) => runtimeProcess.exited)).then( + () => true as const, + ); + const result = await Promise.race([exited, timedOut]); + if (timer) clearTimeout(timer); + return result; +} diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts new file mode 100644 index 0000000000..7edcc0e129 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -0,0 +1,150 @@ +import { stat } from "node:fs/promises"; +import path from "node:path"; + +import { + createWorktree, + deletePaseoWorktree, + getGitCommonDir, + type WorktreeSource, +} from "../../../utils/worktree.js"; +import type { + WorkspaceDriverCreateInput, + WorkspaceDriverInspection, + WorkspaceDriverSpawnInput, + WorkspaceDriverState, + WorkspaceRuntimeDriver, +} from "../drivers/index.js"; +import { resolveRuntimeCwd, spawnHostProcess } from "./host-process.js"; +import { createRuntimeStateStore } from "./runtime-state.js"; + +interface WorktreeRuntimeState extends WorkspaceDriverState { + sourceRoot: string; + worktreeRoot: string; +} + +export function createWorktreeRuntime(options: { + paseoHome: string; + worktreesRoot?: string; +}): WorkspaceRuntimeDriver { + const states = createRuntimeStateStore(options.paseoHome, "worktree"); + + async function inspect(workspaceId: string): Promise { + const state = await states.read(workspaceId); + if (!state) return { status: "missing" }; + try { + if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; + return { status: state.lifecycle, state }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; + return { status: "error", message: String(error) }; + } + } + + async function requireReady(workspaceId: string): Promise { + const inspection = await inspect(workspaceId); + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`); + } + return inspection.state; + } + + return { + id: "worktree", + async create(input: WorkspaceDriverCreateInput) { + const existing = await inspect(input.workspaceId); + if (existing.status === "ready" || existing.status === "paused") return existing.state; + if (input.project.source.kind !== "host-directory") { + throw new Error("The worktree runtime requires a host Git checkout"); + } + if (input.placement.kind === "existing") { + throw new Error("The worktree runtime creates and owns its worktree"); + } + const sourceRoot = path.resolve(input.project.source.path); + const worktree = await createWorktree({ + cwd: sourceRoot, + source: toWorktreeSource(input.placement), + worktreeSlug: input.placement.worktreeSlug ?? input.workspaceId, + runSetup: false, + paseoHome: options.paseoHome, + worktreesRoot: options.worktreesRoot, + }); + try { + const root = await resolveRuntimeCwd(worktree.worktreePath, input.placement.relativeCwd); + if (!(await stat(root)).isDirectory()) { + throw new Error(`Selected project directory is missing from the worktree: ${root}`); + } + const state: WorktreeRuntimeState = { + workspaceId: input.workspaceId, + root, + worktreeRoot: worktree.worktreePath, + sourceRoot, + revision: `worktree:${Date.now()}`, + executionDomainId: "host", + lifecycle: "ready", + }; + await states.write(state); + return state; + } catch (error) { + await deletePaseoWorktree({ + cwd: sourceRoot, + worktreePath: worktree.worktreePath, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); + throw error; + } + }, + inspect, + async spawn(input: WorkspaceDriverSpawnInput) { + return spawnHostProcess((await requireReady(input.workspaceId)).root, input); + }, + async pause(workspaceId) { + const inspection = await inspect(workspaceId); + if (inspection.status === "paused") return; + if (inspection.status !== "ready") { + throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`); + } + await states.write({ ...inspection.state, lifecycle: "paused" }); + }, + async resume(workspaceId) { + const inspection = await inspect(workspaceId); + if (inspection.status === "missing" || inspection.status === "error") { + throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`); + } + const state = { ...inspection.state, lifecycle: "ready" as const }; + await states.write(state); + return state; + }, + async destroy(workspaceId) { + const state = (await states.read(workspaceId)) as WorktreeRuntimeState | null; + if (!state) return; + const sourceRoot = + typeof state.sourceRoot === "string" + ? state.sourceRoot + : path.dirname(await getGitCommonDir(state.root)); + const worktreeRoot = typeof state.worktreeRoot === "string" ? state.worktreeRoot : state.root; + await deletePaseoWorktree({ + cwd: sourceRoot, + worktreePath: worktreeRoot, + teardownCwds: [], + paseoHome: options.paseoHome, + worktreesBaseRoot: options.worktreesRoot, + }); + await states.remove(workspaceId); + }, + }; +} + +function toWorktreeSource( + placement: Exclude, +): WorktreeSource { + if (placement.kind === "branch") { + return { + kind: "branch-off", + baseBranch: placement.baseRef, + branchName: placement.branchName, + }; + } + return { kind: "checkout-branch", branchName: placement.ref }; +} diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts new file mode 100644 index 0000000000..97f9aa8144 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts @@ -0,0 +1,153 @@ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, expect, test, vi } from "vitest"; + +import { createWorkspaceRuntimeService } from "./index.js"; + +const fixtureExecutable = fileURLToPath( + new URL("../test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url), +); +const cleanupRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +test("a trusted registered command is selected and receives secret launch data off argv", async () => { + const fixture = await createFixture("registered"); + await fixture.service.create({ + ...fixture.createInput, + setup: [{ argv: ["/bin/sh", "-c", "printf setup > setup-purpose.txt"], env: {} }], + }); + const setupLaunch = JSON.parse( + await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"), + ) as { purpose: unknown }; + expect(setupLaunch.purpose).toEqual({ kind: "setup" }); + const secret = "secret-shaped-workload-value"; + const process = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + "require('fs').writeFileSync('workload-secret.txt', process.env.SECRET_TOKEN)", + ], + env: { SECRET_TOKEN: secret }, + purpose: { kind: "workspace-script", script: "secure-envelope-contract" }, + }); + process.stdin.end(); + await expect(process.exited).resolves.toEqual({ code: 0, signal: null }); + expect(await readFile(path.join(fixture.source, "workload-secret.txt"), "utf8")).toBe(secret); + const launch = JSON.parse( + await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"), + ) as { argv: string[]; purpose: unknown }; + expect(JSON.stringify(launch.argv)).not.toContain(secret); + expect(launch.purpose).toEqual({ + kind: "workspace-script", + script: "secure-envelope-contract", + }); + + await expect( + fixture.service.create({ + ...fixture.createInput, + workspaceId: "unregistered", + runtimeId: "nope", + }), + ).rejects.toThrow("Workspace runtime is not registered: nope"); + await fixture.service.destroy(fixture.workspaceId); +}); + +test("run admission racing pause cannot leave an unregistered workload running", async () => { + const fixture = await createFixture("race", true); + await fixture.service.create(fixture.createInput); + await writeFile(path.join(fixture.barrierDirectory, "block-next-inspect"), "block"); + + const runPromise = fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)"], + env: {}, + purpose: { kind: "workspace-script", script: "pause-race-contract" }, + }); + await vi.waitFor(() => + expect(existsSync(path.join(fixture.barrierDirectory, "inspect-entered"))).toBe(true), + ); + const pausePromise = fixture.service.pause(fixture.workspaceId); + await writeFile(path.join(fixture.barrierDirectory, "release-inspect"), "release"); + + const workload = await runPromise; + workload.stdin.end(); + await pausePromise; + await expect(workload.exited).resolves.toMatchObject({ code: null }); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "paused-admission-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await fixture.service.resume(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); +}, 15_000); + +test("an existing runtime selection cannot be switched before target driver dispatch", async () => { + const fixture = await createFixture("immutable-selection"); + await fixture.service.create({ ...fixture.createInput, runtimeId: "local" }); + + await expect(fixture.service.create(fixture.createInput)).rejects.toThrow( + `Workspace runtime is already selected as local: ${fixture.workspaceId}`, + ); + expect(await readdir(fixture.stateDirectory)).toEqual([]); + + await fixture.service.destroy(fixture.workspaceId); +}); + +async function createFixture(name: string, withBarrier = false) { + const root = await mkdtemp(path.join(tmpdir(), `paseo-command-runtime-${name}-`)); + cleanupRoots.push(root); + const source = path.join(root, "source"); + const stateDirectory = path.join(root, "state"); + const barrierDirectory = path.join(root, "barrier"); + await Promise.all([mkdir(source), mkdir(stateDirectory), mkdir(barrierDirectory)]); + const runtimeIds = new Map(); + const workspaceId = `${name}-workspace`; + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + externalRuntimes: { + fixture: { + type: "command", + command: [processExecPath(), fixtureExecutable], + options: { + stateDirectory, + ...(withBarrier ? { inspectBarrierDirectory: barrierDirectory } : {}), + }, + }, + }, + }); + return { + source, + stateDirectory, + barrierDirectory, + workspaceId, + service, + createInput: { + workspaceId, + runtimeId: "fixture", + project: { id: `${name}-project`, source: { kind: "host-directory" as const, path: source } }, + placement: { kind: "existing" as const }, + }, + }; +} + +function processExecPath(): string { + return process.execPath; +} diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts new file mode 100644 index 0000000000..e6b91bbb2d --- /dev/null +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts @@ -0,0 +1,405 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterAll, beforeAll, expect, test, vi } from "vitest"; + +import { createWorkspaceRuntimeService } from "./index.js"; + +const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); +const dockerRuntimeRoot = path.join(repositoryRoot, "packages/docker-workspace-runtime"); +const runtimeExecutable = path.join(dockerRuntimeRoot, "src/index.ts"); +const image = "paseo-workspace-runtime-slice1:test"; +const cleanupRoots: string[] = []; + +beforeAll(() => { + execFileSync("docker", ["build", "-t", image, dockerRuntimeRoot], { stdio: "pipe" }); +}, 120_000); + +afterAll(async () => { + await Promise.all(cleanupRoots.map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function collect(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +test("the command runtime owns Docker workspace materialization, pipes, and lifecycle", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-")); + cleanupRoots.push(root); + const repo = await createRepository(root); + await writeFile(path.join(repo, ".env"), "must-not-enter-runtime\n"); + const runtimeIds = new Map(); + const options = { + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (workspaceId: string) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId: string, runtimeId: string) => { + runtimeIds.set(workspaceId, runtimeId); + }, + externalRuntimes: { + docker: { + type: "command", + command: [process.execPath, "--import", "tsx", runtimeExecutable] as const, + options: { image }, + }, + }, + }; + const service = createWorkspaceRuntimeService(options); + const workspaceId = `docker-${Date.now()}`; + + try { + const workspace = await service.create({ + workspaceId, + runtimeId: "docker", + project: { + id: "docker-project", + source: { kind: "host-directory", path: repo }, + }, + placement: { kind: "existing" }, + }); + expect(workspace).toEqual({ workspaceId, runtimeId: "docker" }); + expect("cwd" in workspace).toBe(false); + + const write = await service.run({ + workspaceId, + argv: ["/bin/sh", "-c", "cat > runtime-owned.txt; printf runtime-stderr >&2; exit 19"], + env: {}, + purpose: { kind: "workspace-script", script: "write-contract" }, + }); + write.stdin.end("docker-state"); + const [stderr, exit] = await Promise.all([collect(write.stderr), write.exited]); + expect(stderr).toBe("runtime-stderr"); + expect(exit).toEqual({ code: 19, signal: null }); + + const env = await service.run({ + workspaceId, + argv: ["/usr/bin/env"], + env: { RUNTIME_EXACT_ENV: "docker" }, + purpose: { kind: "workspace-script", script: "environment-contract" }, + }); + env.stdin.end(); + await expect(collect(env.stdout)).resolves.toBe("RUNTIME_EXACT_ENV=docker\n"); + await expect(env.exited).resolves.toEqual({ code: 0, signal: null }); + + await service.pause(workspaceId); + await expect( + service.run({ + workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "paused-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${workspaceId}`); + + const recovered = createWorkspaceRuntimeService(options); + await recovered.resume(workspaceId); + await expect( + recovered.run({ + workspaceId, + cwd: "../escape", + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "cwd-contract" }, + }), + ).rejects.toThrow("Workspace cwd must stay within the runtime root"); + const read = await recovered.run({ + workspaceId, + argv: ["/bin/cat", "runtime-owned.txt"], + env: {}, + purpose: { kind: "workspace-script", script: "read-contract" }, + }); + read.stdin.end(); + await expect(collect(read.stdout)).resolves.toBe("docker-state"); + await expect(read.exited).resolves.toEqual({ code: 0, signal: null }); + const committedOnly = await recovered.run({ + workspaceId, + argv: ["/bin/sh", "-c", "test ! -e .env"], + env: {}, + purpose: { kind: "workspace-script", script: "materialization-contract" }, + }); + committedOnly.stdin.end(); + await expect(committedOnly.exited).resolves.toEqual({ code: 0, signal: null }); + expect(execFileSync("git", ["status", "--short"], { cwd: repo, encoding: "utf8" })).toBe( + "?? .env\n", + ); + + const killed = await recovered.run({ + workspaceId, + argv: ["/bin/sh", "-c", "sleep 30 & echo ready; wait"], + env: { PATH: "/usr/bin:/bin" }, + purpose: { kind: "workspace-script", script: "signal-contract" }, + }); + killed.stdin.end(); + await expect(waitForOutput(killed.stdout, "ready")).resolves.toContain("ready"); + const killedAt = Date.now(); + killed.kill("SIGTERM"); + await expect(killed.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); + expect(Date.now() - killedAt).toBeLessThan(5_000); + await vi.waitFor(() => expect(dockerProcesses(workspaceId)).not.toContain("sleep 30")); + + const stubborn = await recovered.run({ + workspaceId, + argv: [ + "/usr/local/bin/node", + "-e", + "process.on('SIGTERM',()=>{});console.log('stubborn-ready');setInterval(()=>{},1000)", + ], + env: {}, + purpose: { kind: "workspace-script", script: "escalation-contract" }, + }); + stubborn.stdin.end(); + await expect(waitForOutput(stubborn.stdout, "stubborn-ready")).resolves.toContain( + "stubborn-ready", + ); + const pauseStartedAt = Date.now(); + await recovered.pause(workspaceId); + expect(Date.now() - pauseStartedAt).toBeLessThan(5_000); + await expect(stubborn.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); + expect( + execFileSync( + "docker", + ["inspect", "--format", "{{.State.Running}}", dockerResourceName(workspaceId)], + { + encoding: "utf8", + }, + ).trim(), + ).toBe("false"); + + await recovered.destroy(workspaceId); + expect(dockerResourceCount("ps", workspaceId)).toBe(0); + expect(dockerResourceCount("volume", workspaceId)).toBe(0); + expect(await readFileFromHost(repo, "runtime-owned.txt")).toBeNull(); + await expect( + recovered.run({ + workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "missing-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is missing: ${workspaceId}`); + } finally { + await service.destroy(workspaceId).catch(() => undefined); + } +}, 120_000); + +test("Docker creation validates roots and rolls back when runtime selection cannot persist", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-rollback-")); + cleanupRoots.push(root); + const repo = await createRepository(root); + const workspaceId = `docker-rollback-${Date.now()}`; + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async () => null, + persistRuntimeId: async () => { + throw new Error("persistence failed"); + }, + externalRuntimes: { + docker: { + type: "command", + command: [process.execPath, "--import", "tsx", runtimeExecutable], + options: { image }, + }, + }, + }); + + await expect( + service.create({ + workspaceId: `${workspaceId}-escape`, + runtimeId: "docker", + project: { id: "docker-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing", relativeCwd: "../escape" }, + }), + ).rejects.toThrow("Workspace cwd escapes its root"); + expect(dockerResourceCount("ps", `${workspaceId}-escape`)).toBe(0); + expect(dockerResourceCount("volume", `${workspaceId}-escape`)).toBe(0); + + await expect( + service.create({ + workspaceId, + runtimeId: "docker", + project: { id: "docker-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow("persistence failed"); + expect(dockerResourceCount("ps", workspaceId)).toBe(0); + expect(dockerResourceCount("volume", workspaceId)).toBe(0); +}, 120_000); + +test("Docker rejects a committed symlink cwd that resolves outside the selected root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-symlink-")); + cleanupRoots.push(root); + const repo = await createRepository(root); + await mkdir(path.join(repo, "selected")); + await symlink("..", path.join(repo, "selected", "escape")); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "symlink fixture"], { + cwd: repo, + }); + const workspaceId = `docker-symlink-${Date.now()}`; + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + externalRuntimes: { + docker: { + type: "command", + command: [process.execPath, "--import", "tsx", runtimeExecutable], + options: { image }, + }, + }, + }); + + try { + await service.create({ + workspaceId, + runtimeId: "docker", + project: { id: "symlink-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing", relativeCwd: "selected" }, + }); + const escaped = await service.run({ + workspaceId, + cwd: "escape", + argv: ["/bin/sh", "-c", "touch escaped-marker"], + env: {}, + purpose: { kind: "workspace-script", script: "realpath-confinement" }, + }); + escaped.stdin.end(); + const [escapedError, escapedExit] = await Promise.all([ + collect(escaped.stderr), + escaped.exited, + ]); + expect(escapedError).toContain("Workspace cwd escapes its root"); + expect(escapedExit.code).not.toBe(0); + const verify = await service.run({ + workspaceId, + argv: ["/bin/sh", "-c", "test ! -e ../escaped-marker"], + env: {}, + purpose: { kind: "workspace-script", script: "realpath-confinement-proof" }, + }); + verify.stdin.end(); + await expect(verify.exited).resolves.toEqual({ code: 0, signal: null }); + } finally { + await service.destroy(workspaceId).catch(() => undefined); + } +}, 120_000); + +test("the Docker runtime never adopts or destroys an unowned deterministic resource", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-ownership-")); + cleanupRoots.push(root); + const workspaceId = `docker-ownership-${Date.now()}`; + const volume = dockerResourceName(workspaceId); + execFileSync("docker", ["volume", "create", volume]); + const runtimeIds = new Map([[workspaceId, "docker"]]); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => { + runtimeIds.set(id, runtimeId); + }, + externalRuntimes: { + docker: { + type: "command", + command: [process.execPath, "--import", "tsx", runtimeExecutable], + options: { image }, + }, + }, + }); + + try { + await expect( + service.create({ + workspaceId, + runtimeId: "docker", + project: { id: "ownership-project", source: { kind: "host-directory", path: root } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow(`Docker volume ownership mismatch: ${volume}`); + await expect(service.destroy(workspaceId)).rejects.toThrow( + `Docker volume ownership mismatch: ${volume}`, + ); + expect( + execFileSync("docker", ["volume", "inspect", "--format", "{{.Name}}", volume], { + encoding: "utf8", + }).trim(), + ).toBe(volume); + } finally { + execFileSync("docker", ["volume", "rm", volume]); + } +}, 30_000); + +async function createRepository(root: string): Promise { + const repo = path.join(root, "repo"); + await mkdir(repo); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo }); + await writeFile(path.join(repo, "committed.txt"), "committed\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { + cwd: repo, + }); + return repo; +} + +async function waitForOutput(stream: NodeJS.ReadableStream, marker: string): Promise { + return new Promise((resolve, reject) => { + let output = ""; + const onData = (chunk: Buffer | string) => { + output += chunk.toString(); + if (!output.includes(marker)) return; + stream.off("data", onData); + stream.off("error", onError); + stream.off("end", onEnd); + resolve(output); + }; + const onError = (error: Error) => { + stream.off("data", onData); + stream.off("end", onEnd); + reject(error); + }; + const onEnd = () => { + stream.off("data", onData); + stream.off("error", onError); + reject(new Error(`Stream ended before output marker: ${marker}`)); + }; + stream.on("data", onData); + stream.once("error", onError); + stream.once("end", onEnd); + }); +} + +function dockerProcesses(workspaceId: string): string { + const container = dockerResourceName(workspaceId); + return execFileSync("docker", ["top", container, "-eo", "pid,args"], { encoding: "utf8" }); +} + +function dockerResourceName(workspaceId: string): string { + return `paseo-ws-${createHash("sha256").update(workspaceId).digest("hex").slice(0, 20)}`; +} + +function dockerResourceCount(kind: "ps" | "volume", workspaceId: string): number { + const args = + kind === "ps" + ? ["ps", "-aq", "--filter", `label=sh.paseo.workspace-id=${workspaceId}`] + : ["volume", "ls", "-q", "--filter", `label=sh.paseo.workspace-id=${workspaceId}`]; + return execFileSync("docker", args, { encoding: "utf8" }).trim() ? 1 : 0; +} + +async function readFileFromHost(root: string, name: string): Promise { + try { + return await (await import("node:fs/promises")).readFile(path.join(root, name), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts new file mode 100644 index 0000000000..8f8458f97e --- /dev/null +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -0,0 +1,372 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + createWorkspaceRuntimeService, + type CreateWorkspaceInput, + type WorkspaceRuntimeOptions, + type WorkspaceRuntimeService, +} from "./index.js"; + +const cleanupRoots: string[] = []; +const posixDescribe = describe.runIf(process.platform !== "win32"); + +afterEach(async () => { + await Promise.all( + cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", (runtimeId) => { + test("creates, pipes an exact environment, preserves status, and owns only its resources", async () => { + const fixture = await createFixture(runtimeId); + const created = await fixture.service.create(fixture.createInput); + expect(created).toEqual({ workspaceId: fixture.workspaceId, runtimeId }); + expect("cwd" in created).toBe(false); + + const runtimeRoot = fixture.runtimeRoot(); + expect(await readFile(path.join(runtimeRoot, "committed.txt"), "utf8")).toBe("committed\n"); + if (runtimeId === "worktree") { + expect(await readFile(path.join(runtimeRoot, "setup-owned.txt"), "utf8")).toBe("setup\n"); + } + + const child = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + "/bin/sh", + "-c", + "cat > runtime-owned.txt; printf runtime-stdout; printf runtime-stderr >&2; exit 23", + ], + env: { RUNTIME_EXACT_ENV: runtimeId }, + purpose: { kind: "workspace-script", script: "public-contract" }, + }); + child.stdin.end(`${runtimeId}-state`); + const [stdout, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + child.exited, + ]); + expect({ stdout, stderr, exit }).toEqual({ + stdout: "runtime-stdout", + stderr: "runtime-stderr", + exit: { code: 23, signal: null }, + }); + + const env = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/usr/bin/env"], + env: { RUNTIME_EXACT_ENV: runtimeId }, + purpose: { kind: "workspace-script", script: "environment-contract" }, + }); + env.stdin.end(); + await expect(collect(env.stdout)).resolves.toBe(`RUNTIME_EXACT_ENV=${runtimeId}\n`); + await expect(env.exited).resolves.toEqual({ code: 0, signal: null }); + + const signaled = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/sleep", "30"], + env: {}, + purpose: { kind: "workspace-script", script: "signal-contract" }, + }); + signaled.stdin.end(); + signaled.kill("SIGTERM"); + await expect(signaled.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); + + await fixture.service.pause(fixture.workspaceId); + const recovered = createWorkspaceRuntimeService(fixture.options); + await expect( + recovered.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "paused-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await recovered.resume(fixture.workspaceId); + expect(await readFile(path.join(runtimeRoot, "runtime-owned.txt"), "utf8")).toBe( + `${runtimeId}-state`, + ); + + await recovered.destroy(fixture.workspaceId); + expect(existsSync(fixture.repo)).toBe(true); + expect(existsSync(runtimeRoot)).toBe(runtimeId === "local"); + await expect( + recovered.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "missing-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is missing: ${fixture.workspaceId}`); + }); + + test("repeated create does not rerun setup or change paused state", async () => { + const fixture = await createFixture(runtimeId); + const setupMarker = "idempotent-setup.txt"; + const input = { + ...fixture.createInput, + setup: [ + { + argv: ["/bin/sh", "-c", `printf 'setup\\n' >> ${setupMarker}`] as const, + env: {}, + }, + ], + }; + await fixture.service.create(input); + await fixture.service.pause(fixture.workspaceId); + await fixture.service.create(input); + expect(await readFile(path.join(fixture.runtimeRoot(), setupMarker), "utf8")).toBe("setup\n"); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "idempotence-contract" }, + }), + ).rejects.toThrow(`Workspace runtime is paused: ${fixture.workspaceId}`); + await fixture.service.resume(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); +}); + +posixDescribe("fail-closed and producer cleanup", () => { + test("missing and unregistered selections never fall back to the host", async () => { + const root = await temporaryRoot("fail-closed"); + const runtimeIds = new Map(); + const service = createService(root, runtimeIds); + await expect( + service.run({ + workspaceId: "missing", + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "missing-selection" }, + }), + ).rejects.toThrow("Workspace runtime is not selected: missing"); + runtimeIds.set("missing", "not-registered"); + await expect( + service.run({ + workspaceId: "missing", + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "unregistered-selection" }, + }), + ).rejects.toThrow("Workspace runtime is not registered: not-registered"); + await expect( + service.create({ + workspaceId: "unknown", + runtimeId: "not-registered", + project: { id: "project", source: { kind: "host-directory", path: root } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow("Workspace runtime is not registered: not-registered"); + }); + + test("persistence failure destroys the newly-created worktree", async () => { + const root = await temporaryRoot("persist-cleanup"); + const repo = await createRepository(root); + const worktreesRoot = path.join(root, "worktrees"); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + worktreesRoot, + resolveRuntimeId: async () => null, + persistRuntimeId: async () => { + throw new Error("persistence failed"); + }, + }); + await expect( + service.create({ + workspaceId: "../../hostile-id", + runtimeId: "worktree", + project: { id: "project", source: { kind: "host-directory", path: repo } }, + placement: { + kind: "branch", + branchName: "persist-cleanup", + baseRef: "main", + worktreeSlug: "persist-cleanup", + }, + }), + ).rejects.toThrow("persistence failed"); + expect(listLinkedWorktrees(repo)).toHaveLength(1); + expect(existsSync(path.join(root, "hostile-id.json"))).toBe(false); + }); + + test("persistence failure removes newly-created local driver state", async () => { + const root = await temporaryRoot("local-persist-cleanup"); + const repo = await createRepository(root); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async () => null, + persistRuntimeId: async () => { + throw new Error("persistence failed"); + }, + }); + await expect( + service.create({ + workspaceId: "../../hostile-local-id", + runtimeId: "local", + project: { id: "project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow("persistence failed"); + await expect( + service.run({ + workspaceId: "../../hostile-local-id", + argv: ["/bin/true"], + env: {}, + purpose: { kind: "workspace-script", script: "cleanup-contract" }, + }), + ).rejects.toThrow("Workspace runtime is not selected"); + expect(existsSync(path.join(root, "hostile-local-id.json"))).toBe(false); + }); +}); + +posixDescribe.each(["local", "worktree"] as const)( + "%s runtime confinement and process teardown", + (runtimeId) => { + test("rejects a symlink cwd that resolves outside the runtime root", async () => { + const fixture = await createFixture(runtimeId); + await fixture.service.create(fixture.createInput); + const outside = path.join(fixture.root, "outside"); + await mkdir(outside); + await symlink(outside, path.join(fixture.runtimeRoot(), "escape")); + await expect( + fixture.service.run({ + workspaceId: fixture.workspaceId, + cwd: "escape", + argv: ["/bin/pwd"], + env: {}, + purpose: { kind: "workspace-script", script: "cwd-contract" }, + }), + ).rejects.toThrow("Workspace cwd escapes its runtime root"); + await fixture.service.destroy(fixture.workspaceId); + }); + + test("pause escalates past ignored SIGTERM and leaves no descendant", async () => { + const fixture = await createFixture(runtimeId); + await fixture.service.create(fixture.createInput); + const child = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + process.execPath, + "-e", + "process.on('SIGTERM',()=>{});const c=require('child_process').spawn(process.execPath,['-e',\"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)\"],{stdio:'ignore'});require('fs').writeFileSync('descendant.pid',String(c.pid));setInterval(()=>{},1000)", + ], + env: {}, + purpose: { kind: "workspace-script", script: "teardown-contract" }, + }); + child.stdin.end(); + const pidFile = path.join(fixture.runtimeRoot(), "descendant.pid"); + await vi.waitFor(() => expect(existsSync(pidFile)).toBe(true)); + const descendantPid = Number((await readFile(pidFile, "utf8")).trim()); + const startedAt = Date.now(); + await fixture.service.pause(fixture.workspaceId); + expect(Date.now() - startedAt).toBeLessThan(4_000); + await expect(child.exited).resolves.toEqual({ code: null, signal: "SIGKILL" }); + await vi.waitFor(() => expect(isProcessAlive(descendantPid)).toBe(false)); + await fixture.service.resume(fixture.workspaceId); + await fixture.service.destroy(fixture.workspaceId); + }); + }, +); + +async function createFixture(runtimeId: "local" | "worktree") { + const root = await temporaryRoot(runtimeId); + const repo = await createRepository(root); + const runtimeIds = new Map(); + const worktreesRoot = path.join(root, "worktrees"); + const options: WorkspaceRuntimeOptions = { + paseoHome: path.join(root, "home"), + worktreesRoot, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, selectedRuntimeId) => { + runtimeIds.set(workspaceId, selectedRuntimeId); + }, + }; + const workspaceId = `${runtimeId}-workspace`; + const createInput: CreateWorkspaceInput = { + workspaceId, + runtimeId, + project: { id: `${runtimeId}-project`, source: { kind: "host-directory", path: repo } }, + placement: + runtimeId === "local" + ? { kind: "existing" } + : { + kind: "branch", + branchName: "runtime-branch", + baseRef: "main", + worktreeSlug: "runtime-worktree", + }, + setup: + runtimeId === "worktree" + ? [{ argv: ["/bin/sh", "-c", "printf 'setup\\n' > setup-owned.txt"], env: {} }] + : undefined, + }; + return { + root, + repo, + options, + workspaceId, + createInput, + service: createWorkspaceRuntimeService(options), + runtimeRoot: () => + runtimeId === "local" + ? repo + : listLinkedWorktrees(repo).find((cwd) => path.basename(cwd) === "runtime-worktree")!, + }; +} + +function createService(root: string, runtimeIds: Map): WorkspaceRuntimeService { + return createWorkspaceRuntimeService({ + paseoHome: path.join(root, "home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + }); +} + +async function temporaryRoot(name: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), `paseo-runtime-${name}-`)); + cleanupRoots.push(root); + return root; +} + +async function createRepository(root: string): Promise { + const repo = path.join(root, "repo"); + await mkdir(repo); + execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo }); + await writeFile(path.join(repo, "committed.txt"), "committed\n"); + execFileSync("git", ["add", "."], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { cwd: repo }); + return repo; +} + +function listLinkedWorktrees(repo: string): string[] { + return execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repo, encoding: "utf8" }) + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => line.slice("worktree ".length)); +} + +async function collect(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +} diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 033b496130..9addff50ae 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -179,6 +179,32 @@ }, "additionalProperties": false }, + "workspaceRuntimes": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "command" + }, + "command": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + "options": { + "type": "object", + "additionalProperties": {} + } + }, + "required": ["type", "command"], + "additionalProperties": false + } + }, "providers": { "type": "object", "properties": {