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.
This commit is contained in:
Mohamed Boudra
2026-08-10 22:57:54 +02:00
parent a18d9ec3d8
commit 4898523351
36 changed files with 3054 additions and 13 deletions
+24
View File
@@ -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'
+17 -1
View File
@@ -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",
+2 -1
View File
@@ -30,7 +30,8 @@
"packages/relay",
"packages/website",
"packages/desktop",
"packages/cli"
"packages/cli",
"packages/docker-workspace-runtime"
],
"scripts": {
"dev": "npm run dev:server",
@@ -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
@@ -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"
}
}
@@ -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<string, string>;
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<RuntimeState> {
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<void> {
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<RuntimeState> {
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<void> {
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<string, string> | null }>;
return { owner: inspection[0]?.Labels?.["sh.paseo.workspace-id"] ?? null };
}
async function execute(workspaceId: string): Promise<void> {
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<void> => {
forwardedSignal = signal;
await signalExec(workspaceId, execId, signal);
};
const hostSignalHandlers = new Map<NodeJS.Signals, () => 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<void> {
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<void> {
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<string> {
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<string> {
return readStream(process.stdin);
}
async function readOneShotEnvelope(): Promise<string> {
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<string> {
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;
}
@@ -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;
});
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"incremental": false,
"noEmit": false,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+1
View File
@@ -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\"",
+31
View File
@@ -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<Record<string, unknown>>;
}
>
>;
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: {
+82 -2
View File
@@ -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<string, string>();
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(
+1
View File
@@ -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,
@@ -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 } });
@@ -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({
@@ -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(),
+25 -1
View File
@@ -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({
@@ -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<T>(): { promise: Promise<T>; 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<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
let mutationListener: ((mutation: WorkspaceMutation) => void | Promise<void>) | 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<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
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",
});
});
@@ -57,7 +57,7 @@ export interface WorkspaceProvisioningService {
cwd: string,
title?: string | null,
projectId?: string,
context?: { expectsInitialAgent?: boolean },
context?: { expectsInitialAgent?: boolean; runtimeId?: string },
): Promise<PersistedWorkspaceRecord>;
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<PersistedWorkspaceRecord> {
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,
});
@@ -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`);
}
@@ -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,
@@ -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;
@@ -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<typeof PersistedProjectRecordSchema>;
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>;
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,
@@ -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<Record<string, unknown>>;
}
export function createCommandRuntimeAdapter(
runtimeId: string,
config: CommandRuntimeAdapterConfig,
): WorkspaceRuntimeDriver {
return createCommandRuntime(runtimeId, config);
}
@@ -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<Record<string, unknown>>;
}
export function createCommandRuntime(
runtimeId: string,
config: CommandRuntimeConfig,
): WorkspaceRuntimeDriver {
let described: Promise<void> | null = null;
function ensureCompatible(): Promise<void> {
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<WorkspaceDriverInspection> {
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<string> {
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<Record<string, unknown>>,
): 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<string> {
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;
}
@@ -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"])),
});
@@ -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<Record<string, string>>;
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<WorkspaceProcessExit>;
kill(signal?: NodeJS.Signals): void;
}
export interface WorkspaceRuntimeDriver {
readonly id: WorkspaceRuntimeId;
create(input: WorkspaceDriverCreateInput): Promise<WorkspaceDriverState>;
inspect(workspaceId: WorkspaceId): Promise<WorkspaceDriverInspection>;
spawn(input: WorkspaceDriverSpawnInput): Promise<WorkspacePipeProcess>;
pause(workspaceId: WorkspaceId): Promise<void>;
resume(workspaceId: WorkspaceId): Promise<WorkspaceDriverState>;
destroy(workspaceId: WorkspaceId): Promise<void>;
}
@@ -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<Record<string, string>>;
}
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<WorkspaceProcessExit>;
kill(signal?: NodeJS.Signals): void;
}
export interface WorkspaceRuntimeService {
create(input: CreateWorkspaceInput): Promise<{ workspaceId: string; runtimeId: string }>;
run(input: WorkspaceProcessInput): Promise<WorkspaceProcess>;
pause(workspaceId: string): Promise<void>;
resume(workspaceId: string): Promise<void>;
destroy(workspaceId: string): Promise<void>;
}
export interface WorkspaceRuntimeRecordStore {
resolveRuntimeId(workspaceId: string): Promise<string | null>;
persistRuntimeId(workspaceId: string, runtimeId: string): Promise<void>;
}
export interface ExternalWorkspaceRuntime {
type: "command";
command: readonly [string, ...string[]];
options?: Readonly<Record<string, unknown>>;
}
export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore {
paseoHome: string;
worktreesRoot?: string;
externalRuntimes?: Readonly<Record<string, ExternalWorkspaceRuntime>>;
}
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,
);
}
@@ -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<WorkspacePipeProcess> {
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<WorkspaceProcessExit>((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<string> {
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;
}
@@ -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<WorkspaceDriverInspection> {
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<WorkspaceDriverState> {
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);
},
};
}
@@ -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<WorkspaceDriverState | null> {
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<void> {
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<void> {
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<WorkspaceDriverState>;
return (
state.workspaceId === workspaceId &&
typeof state.root === "string" &&
typeof state.revision === "string" &&
typeof state.executionDomainId === "string" &&
(state.lifecycle === "ready" || state.lifecycle === "paused")
);
}
@@ -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<string, Set<WorkspacePipeProcess>>();
const workspaceTails = new Map<string, Promise<void>>();
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<WorkspaceRuntimeDriver> {
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<WorkspaceProcess> {
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<T>(workspaceId: string, operation: () => Promise<T>): Promise<T> {
const previous = workspaceTails.get(workspaceId) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((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<void> {
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<void> {
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<boolean> {
if (processes.length === 0) return true;
let timer: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<false>((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;
}
@@ -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<WorkspaceDriverInspection> {
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<WorkspaceDriverState> {
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<WorkspaceDriverCreateInput["placement"], { kind: "existing" }>,
): WorktreeSource {
if (placement.kind === "branch") {
return {
kind: "branch-off",
baseBranch: placement.baseRef,
branchName: placement.branchName,
};
}
return { kind: "checkout-branch", branchName: placement.ref };
}
@@ -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<string, string>();
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;
}
@@ -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<string> {
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<string, string>();
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<string, string>();
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<string> {
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<string> {
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<string | null> {
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;
}
}
@@ -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<string, string>();
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<string, string>();
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<string, string>): 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<string> {
const root = await mkdtemp(path.join(tmpdir(), `paseo-runtime-${name}-`));
cleanupRoots.push(root);
return root;
}
async function createRepository(root: string): Promise<string> {
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<string> {
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";
}
}
@@ -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": {