refactor(workspaces): make Docker a generic runtime

The server build, typecheck, and dry-run package pass with the Docker implementation deleted from the tree. paseo-workspace-helper remains a mandatory generic runtime contract requirement.
This commit is contained in:
Mohamed Boudra
2026-08-13 21:59:13 +02:00
parent cbb8295ceb
commit 36d8464068
51 changed files with 781 additions and 464 deletions
+5
View File
@@ -179,11 +179,16 @@ jobs:
- name: Build server stack
run: npm run build:server
- name: Build Docker workspace runtime CLI
run: npm run build:docker-workspace-runtime
- name: Build Docker acceptance image
run: docker build --file packages/docker-workspace-runtime/Dockerfile --tag paseo-workspace-runtime-e2e:test .
- name: Test workspace runtime contract
run: npm run test:workspace-runtime:docker --workspace=@getpaseo/server
env:
E2E_DOCKER_WORKSPACE_IMAGE: paseo-workspace-runtime-e2e:test
- name: Install Playwright browser
run: npx playwright install chromium
+10 -5
View File
@@ -24,9 +24,9 @@ WebSocket requests still require `PASEO_PASSWORD` when one is configured.
## Docker workspace runtime
The Desktop New Workspace screen includes a built-in **Docker** runtime. It is separate from running the Paseo daemon image described on this page: the daemon stays on the host and materializes each selected project's committed Git content into a runtime-owned Docker volume. Files, watching, terminals, Git, scripts, provider discovery, and agents then use the same workspace-runtime boundary.
Paseo Desktop registers the Docker workspace runtime as a generic command runtime. It is separate from running the Paseo daemon image described on this page: the daemon stays on the host and the registered runtime materializes each selected project's committed Git content into a runtime-owned Docker volume. Files, watching, terminals, Git, scripts, provider discovery, and agents then use the same workspace-runtime boundary.
The current POC requires a locally built workspace-runtime image. The configured default, `ghcr.io/getpaseo/workspace-runtime:latest`, is not published, so it does not currently work. Build the image from the repository root and override `workspaceRuntimes.docker.image` in daemon configuration:
The current POC requires the retained `paseo-workspace-runtime:phase3-qa` image. Build another tag from the repository root and replace the opaque `options.image` value when needed:
```bash
docker build -f packages/docker-workspace-runtime/Dockerfile -t paseo-workspace-runtime:local .
@@ -36,14 +36,19 @@ docker build -f packages/docker-workspace-runtime/Dockerfile -t paseo-workspace-
{
"workspaceRuntimes": {
"docker": {
"type": "docker",
"image": "paseo-workspace-runtime:local"
"type": "command",
"label": "Docker",
"command": ["@getpaseo/docker-workspace-runtime"],
"options": {
"image": "paseo-workspace-runtime:local",
"bindMounts": []
}
}
}
}
```
Optional bind mounts require absolute host and container paths, must target outside `/workspace`, and declare `readOnly` explicitly. They are available during Git materialization and in the running workspace. They cannot replace the runtime-owned project volume. Set `enabled: false` to remove Docker from the runtime catalog.
Docker owns the meaning and validation of every value under `options`. Set `workspaceRuntimes.docker` to `null` to remove the distribution registration, or replace it with any command registration. The `docker` key is an ordinary runtime ID.
## Quick Start
+1 -1
View File
@@ -36166,6 +36166,7 @@
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.3.1",
"@getpaseo/docker-workspace-runtime": "*",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"chalk": "^5.3.0",
@@ -38062,7 +38063,6 @@
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.3.1",
"@getpaseo/docker-workspace-runtime": "*",
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
+2 -2
View File
@@ -59,8 +59,8 @@
"build:workspace-runtime-fixture": "npm run build:workspace-runtime-contract && npm run build --workspace=@getpaseo/fixture-workspace-runtime",
"build:docker-workspace-runtime": "npm run build --workspace=@getpaseo/docker-workspace-runtime",
"build:docker-workspace-runtime:clean": "npm run build:clean --workspace=@getpaseo/docker-workspace-runtime",
"build:server-deps": "npm run build:workspace-runtime-contract && concurrently --kill-others-on-fail --names highlight,relay,client,docker-runtime --prefix-colors yellow,blue,cyan,magenta \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\" \"npm run build:docker-workspace-runtime\"",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean && npm run build:docker-workspace-runtime:clean",
"build:server-deps": "npm run build:workspace-runtime-contract && concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"",
"build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean",
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
"build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli",
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
+4 -16
View File
@@ -196,8 +196,10 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void>
...(dockerAcceptance
? {
docker: {
type: "docker",
image: dockerImage,
type: "command",
label: "Docker",
command: ["@getpaseo/docker-workspace-runtime"],
options: { image: dockerImage, bindMounts: [] },
},
}
: {}),
@@ -208,13 +210,6 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void>
process.execPath,
path.resolve(__dirname, "../../../../fixture-workspace-runtime/src/index.mjs"),
],
helperCommand: [
process.execPath,
path.resolve(
__dirname,
"../../../../server/src/server/workspace-helper/executable.mjs",
),
],
options: {
stateDirectory,
materializeRoot,
@@ -229,13 +224,6 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void>
process.execPath,
path.resolve(__dirname, "../../../../fixture-workspace-runtime/src/index.mjs"),
],
helperCommand: [
process.execPath,
path.resolve(
__dirname,
"../../../../server/src/server/workspace-helper/executable.mjs",
),
],
options: {
stateDirectory: path.join(paseoHome, "fixture-failure-runtime"),
failCreate: true,
+1 -1
View File
@@ -1042,7 +1042,7 @@ export const ar: TranslationResources = {
newWorkspace: {
title: "مساحة عمل جديدة",
create: "يخلق",
runtime: { local: "محلي", worktree: "شجرة العمل", docker: "Docker", label: "بيئة التشغيل" },
runtime: { local: "محلي", worktree: "شجرة العمل", label: "بيئة التشغيل" },
runtimeProbe: { preparing: "جارٍ تجهيز البيئة" },
isolation: {
local: "محلي",
+1 -1
View File
@@ -1052,7 +1052,7 @@ export const en = {
newWorkspace: {
title: "New workspace",
create: "Create",
runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Runtime" },
runtime: { local: "Local", worktree: "Worktree", label: "Runtime" },
runtimeProbe: { preparing: "Preparing environment" },
isolation: {
local: "Local",
+1 -1
View File
@@ -1073,7 +1073,7 @@ export const es: TranslationResources = {
newWorkspace: {
title: "Nuevo espacio de trabajo",
create: "Crear",
runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Entorno" },
runtime: { local: "Local", worktree: "Worktree", label: "Entorno" },
runtimeProbe: { preparing: "Preparando el entorno" },
isolation: {
local: "Local",
+1 -1
View File
@@ -1072,7 +1072,7 @@ export const fr: TranslationResources = {
newWorkspace: {
title: "Nouvel espace de travail",
create: "Créer",
runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Environnement" },
runtime: { local: "Local", worktree: "Worktree", label: "Environnement" },
runtimeProbe: { preparing: "Préparation de l’environnement" },
isolation: {
local: "Local",
+1 -1
View File
@@ -1053,7 +1053,7 @@ export const ja: TranslationResources = {
newWorkspace: {
title: "新しいワークスペース",
create: "作成",
runtime: { local: "ローカル", worktree: "ワークツリー", docker: "Docker", label: "ランタイム" },
runtime: { local: "ローカル", worktree: "ワークツリー", label: "ランタイム" },
runtimeProbe: { preparing: "環境を準備しています" },
isolation: {
local: "ローカル",
+1 -1
View File
@@ -1049,7 +1049,7 @@ export const ko: TranslationResources = {
newWorkspace: {
title: "새 워크스페이스",
create: "생성",
runtime: { local: "로컬", worktree: "워크트리", docker: "Docker", label: "런타임" },
runtime: { local: "로컬", worktree: "워크트리", label: "런타임" },
runtimeProbe: { preparing: "환경 준비 중" },
isolation: {
local: "로컬",
+1 -1
View File
@@ -1064,7 +1064,7 @@ export const ptBR: TranslationResources = {
newWorkspace: {
title: "Novo workspace",
create: "Criar",
runtime: { local: "Local", worktree: "Worktree", docker: "Docker", label: "Ambiente" },
runtime: { local: "Local", worktree: "Worktree", label: "Ambiente" },
runtimeProbe: { preparing: "Preparando o ambiente" },
isolation: {
local: "Local",
+1 -1
View File
@@ -1064,7 +1064,7 @@ export const ru: TranslationResources = {
newWorkspace: {
title: "Новое рабочее пространство",
create: "Создавать",
runtime: { local: "Локально", worktree: "Worktree", docker: "Docker", label: "Среда" },
runtime: { local: "Локально", worktree: "Worktree", label: "Среда" },
runtimeProbe: { preparing: "Подготовка среды" },
isolation: {
local: "Локально",
+1 -1
View File
@@ -1031,7 +1031,7 @@ export const zhCN: TranslationResources = {
newWorkspace: {
title: "新建 workspace",
create: "创建",
runtime: { local: "本地", worktree: "Worktree", docker: "Docker", label: "运行环境" },
runtime: { local: "本地", worktree: "Worktree", label: "运行环境" },
runtimeProbe: { preparing: "正在准备环境" },
isolation: {
local: "本地",
@@ -74,10 +74,10 @@ describe("new workspace runtime selection", () => {
},
);
test("omits disabled Docker from the selector after authoritative catalog resolution", () => {
test("omits an unavailable runtime after authoritative catalog resolution", () => {
const selection = resolveWorkspaceRuntimeSelection({
catalog: { status: "ready", runtimes: [LOCAL, WORKTREE, FIXTURE] },
selectedRuntimeId: "docker",
selectedRuntimeId: "retired-runtime",
supportsMultiplicity: true,
worktreeSupport: "supported",
});
@@ -95,6 +95,15 @@ describe("new workspace runtime selection", () => {
),
).toBe("Worktree");
});
test("renders registration labels for arbitrary runtime ids without id vocabulary", () => {
expect(
runtimeLabel({ runtimeId: "moon-base", label: "Moon Base" }, "runtime", () => "bad"),
).toBe("Moon Base");
expect(
runtimeLabel({ runtimeId: "shipyard", label: "Container Lab" }, "runtime", () => "bad"),
).toBe("Container Lab");
});
});
describe("new workspace provider probe target", () => {
@@ -82,7 +82,6 @@ export function runtimeLabelKey(
}
if (runtimeId === "local") return "newWorkspace.runtime.local";
if (runtimeId === "worktree") return "newWorkspace.runtime.worktree";
if (runtimeId === "docker") return "newWorkspace.runtime.docker";
return null;
}
+1
View File
@@ -29,6 +29,7 @@
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.3.1",
"@getpaseo/docker-workspace-runtime": "*",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/server": "0.3.1",
"chalk": "^5.3.0",
@@ -98,12 +98,24 @@ const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
const require = createRequire(import.meta.url);
const DISTRIBUTION_WORKSPACE_RUNTIMES = JSON.stringify({
docker: {
type: "command",
label: "Docker",
command: ["@getpaseo/docker-workspace-runtime"],
options: { image: "paseo-workspace-runtime:phase3-qa", bindMounts: [] },
},
});
const DISTRIBUTION_PACKAGE_ROOT = path.resolve(
path.dirname(require.resolve("@getpaseo/docker-workspace-runtime/package.json")),
"..",
"..",
);
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
export const DEFAULT_KILL_TIMEOUT_MS = 3_000;
const require = createRequire(import.meta.url);
const defaultDaemonLaunchRuntime: DaemonLaunchRuntime = {
resolveRunnerEntry: resolveDaemonRunnerEntry,
resolveHome: resolvePaseoHome,
@@ -156,6 +168,8 @@ function buildRunnerArgs(options: DaemonStartOptions): string[] {
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = { ...process.env };
childEnv.PASEO_DISTRIBUTION_WORKSPACE_RUNTIMES = DISTRIBUTION_WORKSPACE_RUNTIMES;
childEnv.PASEO_DISTRIBUTION_PACKAGE_ROOT = DISTRIBUTION_PACKAGE_ROOT;
if (options.home) {
childEnv.PASEO_HOME = options.home;
}
+4 -1
View File
@@ -15,10 +15,13 @@
},
"main": "dist/main.js",
"scripts": {
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
"build": "npm --prefix ../.. run build:docker-workspace-runtime:clean && npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"capture-harness": "./capture-harness/run.sh",
"dev:prerequisites": "npm --prefix ../.. run build:server-deps && npm --prefix ../.. run build:docker-workspace-runtime && npm --prefix ../.. run build --workspace=@getpaseo/server",
"predev": "npm run dev:prerequisites",
"dev": "./scripts/dev.sh",
"predev:win": "npm run dev:prerequisites",
"dev:win": "powershell ./scripts/dev.ps1",
"test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop",
"test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs",
@@ -0,0 +1,128 @@
import { execFile } from "node:child_process";
import {
access,
cp,
mkdir,
mkdtemp,
readFile,
rm,
stat,
symlink,
utimes,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { describe, expect, test } from "vitest";
const desktopDir = path.resolve(import.meta.dirname, "..");
const repoRoot = path.resolve(desktopDir, "../..");
const execFileAsync = promisify(execFile);
const prerequisite = "npm run dev:prerequisites";
describe("desktop dev build contract", () => {
test("runs one prerequisite before either platform dev entry point", async () => {
const packageJson = JSON.parse(await readFile(path.join(desktopDir, "package.json"), "utf8"));
const devScript = await readFile(path.join(import.meta.dirname, "dev.sh"), "utf8");
expect(packageJson.scripts.predev).toBe(prerequisite);
expect(packageJson.scripts["predev:win"]).toBe(prerequisite);
expect(packageJson.scripts["dev:prerequisites"]).toBe(
"npm --prefix ../.. run build:server-deps && npm --prefix ../.. run build:docker-workspace-runtime && npm --prefix ../.. run build --workspace=@getpaseo/server",
);
expect(packageJson.scripts.dev).toBe("./scripts/dev.sh");
expect(packageJson.scripts["dev:win"]).toBe("powershell ./scripts/dev.ps1");
expect(devScript.indexOf("npm run build:main")).toBeLessThan(
devScript.indexOf('exec node "$SCRIPT_DIR/dev-runner.mjs"'),
);
});
test.each(["missing", "stale"])(
"the public prerequisite repairs %s server artifacts in an isolated checkout",
async (initialState) => {
const isolatedRoot = await createIsolatedBuildCheckout();
const serverExport = path.join(isolatedRoot, "packages/server/dist/server/server/exports.js");
const sourceExport = path.join(isolatedRoot, "packages/server/src/server/exports.ts");
if (initialState === "stale") {
await mkdir(path.dirname(serverExport), { recursive: true });
await writeFile(serverExport, "// stale isolated artifact\n");
const staleTime = new Date(0);
await utimes(serverExport, staleTime, staleTime);
}
try {
await runPrerequisite(isolatedRoot);
await expect(access(serverExport)).resolves.toBeUndefined();
expect(await readFile(serverExport, "utf8")).not.toContain("stale isolated artifact");
expect((await stat(serverExport)).mtimeMs).toBeGreaterThanOrEqual(
(await stat(sourceExport)).mtimeMs,
);
} finally {
await rm(isolatedRoot, { recursive: true, force: true });
}
},
70_000,
);
});
async function createIsolatedBuildCheckout() {
const isolatedRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-desktop-build-contract-"));
const packageDirectories = [
"packages/client",
"packages/desktop",
"packages/docker-workspace-runtime",
"packages/highlight",
"packages/protocol",
"packages/relay",
"packages/server",
"packages/workspace-runtime-contract",
];
try {
await Promise.all([
...["package.json", "package-lock.json", "tsconfig.base.json", "tsconfig.json"].map((file) =>
cp(path.join(repoRoot, file), path.join(isolatedRoot, file)),
),
cp(path.join(repoRoot, "scripts"), path.join(isolatedRoot, "scripts"), {
recursive: true,
}),
...packageDirectories.map(async (relativeDirectory) => {
const target = path.join(isolatedRoot, relativeDirectory);
await mkdir(path.dirname(target), { recursive: true });
await cp(path.join(repoRoot, relativeDirectory), target, {
recursive: true,
filter: (source) =>
!source.endsWith(`${path.sep}dist`) &&
!source.endsWith(`${path.sep}node_modules`) &&
!source.endsWith(`${path.sep}test-results`),
});
}),
]);
await symlink(path.join(repoRoot, "node_modules"), path.join(isolatedRoot, "node_modules"));
await Promise.all(
packageDirectories.map((relativeDirectory) =>
symlink(
path.join(repoRoot, relativeDirectory, "node_modules"),
path.join(isolatedRoot, relativeDirectory, "node_modules"),
),
),
);
return isolatedRoot;
} catch (error) {
await rm(isolatedRoot, { recursive: true, force: true });
throw error;
}
}
async function runPrerequisite(isolatedRoot) {
try {
await execFileAsync("npm", ["run", "dev:prerequisites", "--workspace=@getpaseo/desktop"], {
cwd: isolatedRoot,
timeout: 60_000,
});
} catch (error) {
throw new Error(`${error.stdout ?? ""}\n${error.stderr ?? ""}`, { cause: error });
}
}
@@ -398,6 +398,15 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
PASEO_DESKTOP_MANAGED: "1",
PASEO_CLI: getBundledCliShimPath(),
PASEO_WEB_UI_ENABLED: "false",
PASEO_DISTRIBUTION_WORKSPACE_RUNTIMES: JSON.stringify({
docker: {
type: "command",
label: "Docker",
command: ["@getpaseo/docker-workspace-runtime"],
options: { image: "paseo-workspace-runtime:phase3-qa", bindMounts: [] },
},
}),
PASEO_DISTRIBUTION_PACKAGE_ROOT: __dirname,
},
stdio: ["ignore", "ignore", "ignore"],
});
@@ -9,7 +9,7 @@
"main": "./dist/index.js",
"scripts": {
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "tsc -p tsconfig.json",
"build": "tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/index.js', 0o755)\"",
"build:clean": "npm run clean && npm run build",
"typecheck": "tsgo -p tsconfig.json --noEmit"
},
+14 -14
View File
@@ -54,7 +54,7 @@ try {
});
} else if (operation === "reconcile") {
const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin()));
await reconcile(request.workspaceIds ?? [], requireOwner(request.options));
await reconcile(request.workspaceIds ?? [], requireOwner(request.runtimeInstanceId));
writeJson(CommandRuntimeLifecycleResponseSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
type: "ok",
@@ -69,7 +69,7 @@ try {
requestedWorkspaceId,
requireArgument("--exec-id"),
requireSignal(requireArgument("--signal")),
requireOwner(request.options),
requireOwner(request.runtimeInstanceId),
);
} else {
const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin()));
@@ -90,11 +90,11 @@ try {
writeJson(CommandRuntimeLifecycleResponseSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
type: "inspection",
inspection: await inspect(requestedWorkspaceId, requireOwner(request.options)),
inspection: await inspect(requestedWorkspaceId, requireOwner(request.runtimeInstanceId)),
});
break;
case "pause":
await pause(requestedWorkspaceId, requireOwner(request.options));
await pause(requestedWorkspaceId, requireOwner(request.runtimeInstanceId));
writeJson(CommandRuntimeLifecycleResponseSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
type: "ok",
@@ -102,7 +102,7 @@ try {
break;
case "resume":
{
const state = await resume(requestedWorkspaceId, requireOwner(request.options));
const state = await resume(requestedWorkspaceId, requireOwner(request.runtimeInstanceId));
writeJson(CommandRuntimeLifecycleResponseSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
type: "state",
@@ -112,7 +112,7 @@ try {
}
break;
case "destroy":
await destroy(requestedWorkspaceId, requireOwner(request.options));
await destroy(requestedWorkspaceId, requireOwner(request.runtimeInstanceId));
writeJson(CommandRuntimeLifecycleResponseSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
type: "ok",
@@ -131,7 +131,7 @@ async function create(
workspaceId: string,
request: LifecycleRequest,
): Promise<{ state: PrivateRuntimeState; materializedFreshContent: boolean }> {
const owner = requireOwner(request.options);
const owner = requireOwner(request.runtimeInstanceId);
const existing = await inspectPrivate(workspaceId, owner);
if (existing.status === "ready" || existing.status === "paused") {
return { state: existing.state, materializedFreshContent: false };
@@ -456,7 +456,7 @@ async function execute(workspaceId: string): Promise<void> {
const request = CommandRuntimeControlSchema.parse(JSON.parse(first.value));
if (request.type !== "spawn") throw new Error("Runtime exec spawn control is required");
if (!/^[a-f0-9]{32}$/.test(request.execId)) throw new Error("Invalid exec id");
const expectedOwner = requireOwner(request.options);
const expectedOwner = requireOwner(request.runtimeInstanceId);
const current = await inspectPrivate(workspaceId, expectedOwner);
if (current.status !== "ready") throw new Error(`Docker workspace is ${current.status}`);
const requestedCwd = resolveWorkspaceCwd(current.state.root, request.cwd);
@@ -646,7 +646,7 @@ async function executeDockerPty(input: {
input.request.execId,
created.Id,
input.controls,
requireOwner(input.request.options),
requireOwner(input.request.runtimeInstanceId),
(signal) => {
requestedSignal = signal;
},
@@ -670,7 +670,7 @@ async function executeDockerPty(input: {
input.workspaceId,
input.request.execId,
"SIGKILL",
requireOwner(input.request.options),
requireOwner(input.request.runtimeInstanceId),
),
PTY_CLEANUP_TIMEOUT_MS,
"Docker PTY signal helper timed out",
@@ -964,11 +964,11 @@ function requireImage(options: LifecycleRequest["options"]): string {
return options.image;
}
function requireOwner(options: LifecycleRequest["options"]): string {
if (typeof options.owner !== "string" || !/^[a-f0-9]{64}$/.test(options.owner)) {
throw new Error("Docker runtime option owner must be a SHA-256 identity");
function requireOwner(runtimeInstanceId: string): string {
if (!/^[a-f0-9]{64}$/.test(runtimeInstanceId)) {
throw new Error("Runtime instance id must be a SHA-256 identity");
}
return options.owner;
return runtimeInstanceId;
}
function parseDockerJsonValue(value: string): unknown {
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
import { watch } from "node:fs";
import { chmod, cp, copyFile, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Socket } from "node:net";
import { createInterface } from "node:readline";
import * as pty from "node-pty";
@@ -211,7 +212,8 @@ async function execute(id) {
return;
}
const events = new Socket({ fd: 4, readable: false, writable: true });
pipeChild = spawn(envelope.argv[0], envelope.argv.slice(1), {
const argv = resolveFixtureCommand(envelope);
pipeChild = spawn(argv[0], argv.slice(1), {
cwd: await resolveCwd(state.root, envelope.cwd),
env: envelope.env,
detached: true,
@@ -278,6 +280,21 @@ async function execute(id) {
}
}
function resolveFixtureCommand(envelope) {
if (envelope.purpose.kind !== "workspace-helper") return envelope.argv;
const configured = envelope.options.fixtureHelperCommand;
if (Array.isArray(configured) && configured.every((value) => typeof value === "string")) {
return [...configured, ...envelope.argv.slice(1)];
}
return [
process.execPath,
fileURLToPath(
new URL("../../server/src/server/workspace-helper/executable.mjs", import.meta.url),
),
...envelope.argv.slice(1),
];
}
async function executePty(id, envelope, controls, controlStream) {
const events = new Socket({ fd: 4, readable: false, writable: true });
const child = pty.spawn(envelope.argv[0], envelope.argv.slice(1), {
-1
View File
@@ -73,7 +73,6 @@
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.3.1",
"@getpaseo/docker-workspace-runtime": "*",
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
@@ -19,9 +19,6 @@ const fixtureAgent = new URL(
const fixtureRuntime = fileURLToPath(
new URL("../../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
);
const helperExecutable = fileURLToPath(
new URL("../../workspace-helper/executable.mjs", import.meta.url),
);
const posixDescribe = describe.runIf(process.platform !== "win32");
@@ -123,7 +120,6 @@ posixDescribe("ACP workspace terminal execution", () => {
fixture: {
type: "command",
command: [process.execPath, fixtureRuntime],
helperCommand: [process.execPath, helperExecutable],
options: { stateDirectory: fixtureStateDirectory },
},
}
@@ -10,27 +10,27 @@ import type {
BoundWorkspaceRuntime,
WorkspaceProcess,
WorkspaceProcessPurpose,
WorkspaceRuntimeProviderCapability,
} from "../../../workspace-runtime/index.js";
import type { WorkspaceDirectory } from "../../../workspace-helper/index.js";
export interface ProviderPlacementPolicy {
environment:
| { type: "inherit-sanitized-host"; hostEnvironment: ProcessEnvRecord }
| { type: "isolated"; baseEnvironment?: ProcessEnvRecord };
| { type: "isolated" };
sharedHostProviders: ReadonlySet<string>;
}
export function resolveProviderPlacementPolicy(input: {
runtimeId: string;
capability: WorkspaceRuntimeProviderCapability;
hostEnvironment: ProcessEnvRecord;
isolatedEnvironment?: ProcessEnvRecord;
}): ProviderPlacementPolicy {
const hostPlaced = input.runtimeId === "local" || input.runtimeId === "worktree";
return {
environment: hostPlaced
? { type: "inherit-sanitized-host", hostEnvironment: input.hostEnvironment }
: { type: "isolated", baseEnvironment: input.isolatedEnvironment },
sharedHostProviders: hostPlaced ? new Set(["opencode"]) : new Set(),
environment:
input.capability.environment === "inherit-sanitized-host"
? { type: "inherit-sanitized-host", hostEnvironment: input.hostEnvironment }
: { type: "isolated" },
sharedHostProviders: input.capability.sharedHostProviders,
};
}
@@ -110,7 +110,7 @@ export function bindProviderWorkspace(input: {
const baseEnv =
input.policy.environment.type === "inherit-sanitized-host"
? input.policy.environment.hostEnvironment
: (input.policy.environment.baseEnvironment ?? {});
: {};
return createProviderEnv({ baseEnv, overlays: [...overlays] }) as Record<string, string>;
};
const run = (launch: ProviderWorkspaceLaunchInput): Promise<WorkspaceProcess> =>
@@ -7,11 +7,26 @@ import { describe, expect, test } from "vitest";
import type { BoundWorkspaceRuntime } from "../../../workspace-runtime/index.js";
import { createWorkspaceRuntimeService } from "../../../workspace-runtime/index.js";
import { bindProviderWorkspace } from "./index.js";
import { bindProviderWorkspace, resolveProviderPlacementPolicy } from "./index.js";
const posixDescribe = describe.runIf(process.platform !== "win32");
posixDescribe("provider workspace placement capability", () => {
test("derives provider environment and host services from capabilities, not runtime ids", () => {
expect(
resolveProviderPlacementPolicy({
capability: {
environment: "isolated",
sharedHostProviders: new Set(["fixture-host-service"]),
},
hostEnvironment: { HOST_ONLY: "secret" },
}),
).toEqual({
environment: { type: "isolated" },
sharedHostProviders: new Set(["fixture-host-service"]),
});
});
test("streams large binary state without putting content in argv and removes it safely", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-provider-state-"));
const cwd = path.join(root, "workspace");
@@ -32,6 +47,7 @@ posixDescribe("provider workspace placement capability", () => {
const launches: Array<readonly string[]> = [];
const bound = await service.bind("provider-state");
const recordingRuntime: BoundWorkspaceRuntime = {
provider: { environment: "isolated", sharedHostProviders: new Set() },
...bound,
resolveCommand(command) {
return command === "node"
+5 -22
View File
@@ -153,7 +153,6 @@ import {
} from "./workspace-registry.js";
import {
createWorkspaceRuntimeService,
DEFAULT_DOCKER_WORKSPACE_IMAGE,
type WorkspaceRuntimeConfig,
type WorkspaceRuntimeRecordStore,
type WorkspaceRuntimeService,
@@ -404,6 +403,7 @@ export interface PaseoDaemonConfig {
desktopManaged?: boolean;
worktreesRoot?: string;
workspaceRuntimes?: Readonly<Record<string, WorkspaceRuntimeConfig>>;
workspaceRuntimeCommandResolutionBase?: string;
corsAllowedOrigins: string[];
allowedHosts?: HostnamesConfig;
hostnames?: HostnamesConfig;
@@ -868,7 +868,7 @@ export async function createPaseoDaemon(
logger,
);
const providerWorkspaceBindings = new Map<string, ReturnType<typeof bindProviderWorkspace>>();
const bindWorkspaceProviderCapability = async (workspaceId: string, runtimeId: string) => {
const bindWorkspaceProviderCapability = async (workspaceId: string, _runtimeId: string) => {
if (!workspaceRuntime) throw new Error("Workspace runtime is not available");
const cached = providerWorkspaceBindings.get(workspaceId);
if (cached) return cached;
@@ -877,9 +877,8 @@ export async function createPaseoDaemon(
runtime,
cwd: ".",
policy: resolveProviderPlacementPolicy({
runtimeId,
capability: runtime.provider,
hostEnvironment: process.env,
isolatedEnvironment: config.workspaceRuntimes?.[runtimeId]?.providerEnvironment,
}),
});
providerWorkspaceBindings.set(workspaceId, workspace);
@@ -915,7 +914,7 @@ export async function createPaseoDaemon(
return workspaceRuntime.listRuntimes();
},
},
runtimeConfiguration: resolveProviderProbeRuntimeConfiguration(config.workspaceRuntimes),
runtimeConfiguration: config.workspaceRuntimes,
bindWorkspaceProviderCapability,
});
const workspaceRecords: WorkspaceRuntimeRecordStore = {
@@ -957,6 +956,7 @@ export async function createPaseoDaemon(
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
externalRuntimes: config.workspaceRuntimes,
commandResolutionBase: config.workspaceRuntimeCommandResolutionBase,
...workspaceRecords,
});
const detachProviderProbeInvalidation = projectRegistry.subscribeToMutations(async (mutation) => {
@@ -1949,23 +1949,6 @@ export async function createPaseoDaemon(
};
}
function resolveProviderProbeRuntimeConfiguration(
configured: Readonly<Record<string, WorkspaceRuntimeConfig>> | undefined,
): Readonly<Record<string, unknown>> {
const docker = configured?.docker;
return {
...configured,
docker: {
type: "docker",
...(docker?.type === "docker" ? docker : {}),
image:
docker?.type === "docker"
? (docker.image ?? DEFAULT_DOCKER_WORKSPACE_IMAGE)
: DEFAULT_DOCKER_WORKSPACE_IMAGE,
},
};
}
async function closeAllAgents(logger: Logger, agentManager: AgentManager): Promise<void> {
const agents = agentManager.listAgents();
await Promise.all(
+33 -6
View File
@@ -40,7 +40,6 @@ describe("server config", () => {
type: "command",
command: ["/trusted/runtime", "--mode", "fixture"],
options: { image: "fixture:test" },
helperCommand: ["/runtime/bin/workspace-helper"],
},
},
}),
@@ -52,11 +51,42 @@ describe("server config", () => {
type: "command",
command: ["/trusted/runtime", "--mode", "fixture"],
options: { image: "fixture:test" },
helperCommand: ["/runtime/bin/workspace-helper"],
},
});
});
test("lets persisted registrations override or remove distribution registrations", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-runtime-merge-"));
roots.push(paseoHome);
const configPath = path.join(paseoHome, "config.json");
await writeFile(
configPath,
JSON.stringify({
version: 1,
workspaceRuntimes: {
removed: null,
replaced: { type: "command", label: "User", command: ["user-runtime"] },
},
}),
);
await chmod(configPath, 0o600);
expect(
loadConfig(paseoHome, {
env: {
PASEO_DISTRIBUTION_WORKSPACE_RUNTIMES: JSON.stringify({
removed: { type: "command", command: ["bundled-removed"] },
replaced: { type: "command", command: ["bundled-replaced"] },
retained: { type: "command", label: "Retained", command: ["bundled-retained"] },
}),
},
}).workspaceRuntimes,
).toEqual({
replaced: { type: "command", label: "User", command: ["user-runtime"] },
retained: { type: "command", label: "Retained", command: ["bundled-retained"] },
});
});
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);
@@ -76,10 +106,6 @@ describe("server config", () => {
type: "command",
command: [process.execPath, fixtureExecutable],
options: { stateDirectory },
helperCommand: [
process.execPath,
fileURLToPath(new URL("./workspace-helper/executable.mjs", import.meta.url)),
],
},
},
}),
@@ -111,6 +137,7 @@ describe("server config", () => {
runtimeId: "fixture",
cwd: source,
hostVisiblePath: source,
materializedFreshContent: true,
});
await expect(
service.create({ ...createInput, workspaceId: "unknown-runtime", runtimeId: "unknown" }),
+23 -1
View File
@@ -10,6 +10,7 @@ import {
loadPersistedConfig,
LogFormatSchema,
LogLevelSchema,
PersistedConfigSchema,
type PersistedConfig,
} from "./persisted-config.js";
import type { AgentProvider } from "./agent/agent-sdk-types.js";
@@ -470,6 +471,10 @@ export function loadConfig(
): PaseoDaemonConfig {
const env = options?.env ?? process.env;
const persisted = loadPersistedConfig(paseoHome);
const workspaceRuntimes = resolveWorkspaceRuntimeRegistrations(
env.PASEO_DISTRIBUTION_WORKSPACE_RUNTIMES,
persisted.workspaceRuntimes,
);
const listen = resolveListenAddress(env, options?.cli, persisted);
const {
@@ -509,7 +514,8 @@ export function loadConfig(
paseoHome,
desktopManaged: env.PASEO_DESKTOP_MANAGED === "1",
worktreesRoot: resolveWorktreesRoot(paseoHome, persisted),
workspaceRuntimes: persisted.workspaceRuntimes,
workspaceRuntimes,
workspaceRuntimeCommandResolutionBase: env.PASEO_DISTRIBUTION_PACKAGE_ROOT,
corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted),
hostnames,
trustedProxies,
@@ -547,3 +553,19 @@ export function loadConfig(
log: resolveLogConfigFromEnv(env, persisted),
};
}
function resolveWorkspaceRuntimeRegistrations(
distributionJson: string | undefined,
configured: PersistedConfig["workspaceRuntimes"],
): PaseoDaemonConfig["workspaceRuntimes"] {
const distribution = distributionJson
? PersistedConfigSchema.parse({ workspaceRuntimes: JSON.parse(distributionJson) })
.workspaceRuntimes
: undefined;
const merged = { ...distribution, ...configured };
return Object.fromEntries(
Object.entries(merged).filter((entry): entry is [string, NonNullable<(typeof entry)[1]>] =>
Boolean(entry[1]),
),
);
}
@@ -181,15 +181,10 @@ describe("DaemonConfigStore", () => {
...initial,
workspaceRuntimes: {
docker: {
type: "docker",
image: "paseo-workspace:test",
bindMounts: [
{
source: "/host/agent/auth.json",
target: "/root/.agent/auth.json",
readOnly: true,
},
],
type: "command",
label: "Container Lab",
command: ["runtime-package"],
options: { arbitrary: { retained: true } },
},
},
agents: {
@@ -235,15 +230,10 @@ describe("DaemonConfigStore", () => {
enabled: false,
});
expect(persisted.workspaceRuntimes?.docker).toEqual({
type: "docker",
image: "paseo-workspace:test",
bindMounts: [
{
source: "/host/agent/auth.json",
target: "/root/.agent/auth.json",
readOnly: true,
},
],
type: "command",
label: "Container Lab",
command: ["runtime-package"],
options: { arbitrary: { retained: true } },
});
});
@@ -9,7 +9,6 @@ import {
savePersistedConfig,
} from "./persisted-config.js";
import { PRIVATE_FILE_MODE } from "./private-files.js";
import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js";
const MODE_MASK = 0o777;
const PERMISSIVE_FILE_MODE = 0o644;
@@ -139,22 +138,24 @@ describe("PersistedConfigSchema worktrees config", () => {
});
describe("PersistedConfigSchema workspace runtime config", () => {
test("accepts trusted command registrations and rejects an empty executable", () => {
test("accepts generic command registrations and rejects Docker as a public type", () => {
expect(
PersistedConfigSchema.parse({
workspaceRuntimes: {
fixture: {
type: "command",
label: "Fixture",
command: ["/trusted/runtime", "--fixture"],
options: { image: "fixture:test" },
options: { arbitrary: { nested: [true, 3, null] } },
},
},
}).workspaceRuntimes,
).toEqual({
fixture: {
type: "command",
label: "Fixture",
command: ["/trusted/runtime", "--fixture"],
options: { image: "fixture:test" },
options: { arbitrary: { nested: [true, 3, null] } },
},
});
expect(() =>
@@ -162,81 +163,22 @@ describe("PersistedConfigSchema workspace runtime config", () => {
workspaceRuntimes: { invalid: { type: "command", command: [] } },
}),
).toThrow();
});
test("accepts the Docker off switch and rejects command replacement of the reserved id", () => {
expect(
PersistedConfigSchema.parse({
workspaceRuntimes: {
docker: {
type: "docker",
enabled: false,
image: "paseo-workspace:test",
providerEnvironment: { PATH: "/runtime/bin" },
},
},
}).workspaceRuntimes,
).toEqual({
docker: {
type: "docker",
enabled: false,
image: "paseo-workspace:test",
providerEnvironment: { PATH: "/runtime/bin" },
},
});
const configured = PersistedConfigSchema.parse({
workspaceRuntimes: {
docker: { type: "command", command: ["/wrong/runtime"] },
},
});
expect(() =>
createWorkspaceRuntimeService({
paseoHome: "/tmp/paseo-reserved-runtime-test",
externalRuntimes: configured.workspaceRuntimes,
resolveRuntimeId: async () => null,
persistRuntimeId: async () => {},
}),
).toThrow("Workspace runtime id is reserved: docker");
});
test("accepts trusted Docker bind mounts and rejects unsafe workspace targets", () => {
expect(
PersistedConfigSchema.parse({
workspaceRuntimes: {
docker: {
type: "docker",
bindMounts: [
{
source: "/host/credentials.json",
target: "/root/.agent/credentials.json",
readOnly: true,
},
],
image: "paseo-workspace:test",
},
},
}).workspaceRuntimes?.docker,
).toMatchObject({
bindMounts: [
{
source: "/host/credentials.json",
target: "/root/.agent/credentials.json",
readOnly: true,
},
],
});
}),
).toThrow();
});
for (const bindMounts of [
[{ source: "relative", target: "/root/config", readOnly: true }],
[{ source: "/host/config", target: "relative", readOnly: true }],
[{ source: "/host/repo", target: "/workspace", readOnly: true }],
[{ source: "/host/repo", target: "/workspace/nested", readOnly: true }],
]) {
expect(() =>
PersistedConfigSchema.parse({
workspaceRuntimes: { docker: { type: "docker", bindMounts } },
}),
).toThrow();
}
test("uses null as the generic removal marker for a distribution registration", () => {
expect(
PersistedConfigSchema.parse({ workspaceRuntimes: { bundled: null } }).workspaceRuntimes,
).toEqual({ bundled: null });
});
});
+2 -36
View File
@@ -90,47 +90,13 @@ const CommandWorkspaceRuntimeConfigSchema = z
.array(z.string().min(1))
.min(1)
.transform((command) => command as [string, ...string[]]),
options: z.record(z.string(), z.unknown()).optional(),
providerEnvironment: z.record(z.string(), z.string()).optional(),
helperCommand: z
.array(z.string().min(1))
.min(1)
.transform((command) => command as [string, ...string[]])
.optional(),
})
.strict();
const DockerBindMountSchema = z
.object({
source: z.string().min(1).refine(path.isAbsolute, "Bind mount source must be absolute"),
target: z
.string()
.min(1)
.refine(path.posix.isAbsolute, "Bind mount target must be an absolute container path")
.refine((target) => {
const relative = path.posix.relative("/workspace", path.posix.normalize(target));
return relative === ".." || relative.startsWith("../");
}, "Bind mount target must be outside /workspace"),
readOnly: z.boolean(),
})
.strict();
const DockerWorkspaceRuntimeConfigSchema = z
.object({
type: z.literal("docker"),
image: z.string().min(1).optional(),
bindMounts: z.array(DockerBindMountSchema).optional(),
providerEnvironment: z.record(z.string(), z.string()).optional(),
enabled: z.boolean().optional(),
options: z.record(z.string(), z.json()).optional(),
})
.strict();
const WorkspaceRuntimesConfigSchema = z.record(
z.string().min(1),
z.discriminatedUnion("type", [
CommandWorkspaceRuntimeConfigSchema,
DockerWorkspaceRuntimeConfigSchema,
]),
CommandWorkspaceRuntimeConfigSchema.nullable(),
);
const BcryptHashSchema = z.string().regex(/^\$2[aby]\$\d{2}\$[./A-Za-z0-9]{53}$/, {
@@ -1,16 +1,24 @@
import type { WorkspaceRuntimeDriver } from "../drivers/index.js";
import type { WorkspaceRuntimeJsonValue } from "../index.js";
import { createCommandRuntime } from "./internal/command-runtime.js";
export interface CommandRuntimeAdapterConfig {
command: readonly [string, ...string[]];
label?: string;
options?: Readonly<Record<string, unknown>>;
helperCommand?: readonly [string, ...string[]];
options?: Readonly<Record<string, WorkspaceRuntimeJsonValue>>;
}
export function createCommandRuntimeAdapter(
runtimeId: string,
config: CommandRuntimeAdapterConfig,
runtimeInstanceId: string,
packageResolutionBase: string,
pathResolutionBase: string,
): WorkspaceRuntimeDriver {
return createCommandRuntime(runtimeId, config);
return createCommandRuntime(
runtimeId,
config,
runtimeInstanceId,
packageResolutionBase,
pathResolutionBase,
);
}
@@ -1,6 +1,9 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { constants } from "node:os";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import path from "node:path";
import type { Readable, Writable } from "node:stream";
import { StringDecoder } from "node:string_decoder";
@@ -24,18 +27,22 @@ import type {
WorkspacePtyProcess,
WorkspaceRuntimeDriver,
} from "../../drivers/index.js";
import type { WorkspaceRuntimeJsonValue } from "../../index.js";
const COMMAND_RUNTIME_CLEANUP_TIMEOUT_MS = 750;
export interface CommandRuntimeConfig {
command: readonly [string, ...string[]];
options?: Readonly<Record<string, unknown>>;
helperCommand?: readonly [string, ...string[]];
options?: Readonly<Record<string, WorkspaceRuntimeJsonValue>>;
}
export function createCommandRuntime(
runtimeId: string,
config: CommandRuntimeConfig,
runtimeInstanceId: string,
packageResolutionBase: string,
pathResolutionBase: string,
): WorkspaceRuntimeDriver {
const command = resolveRuntimeCommand(config.command, packageResolutionBase, pathResolutionBase);
let described: Promise<ReadonlySet<"pipes" | "pty">> | null = null;
let supportsReconciliation = false;
@@ -63,6 +70,7 @@ export function createCommandRuntime(
[operation, "--workspace-id", workspaceId],
encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
runtimeInstanceId,
input,
options: config.options ?? {},
}),
@@ -75,12 +83,13 @@ export function createCommandRuntime(
return {
id: runtimeId,
requiresGitProject: true,
reconciliationDomainId: JSON.stringify({ command: config.command, options: config.options }),
reconciliationDomainId: JSON.stringify({ command, options: config.options }),
workspaceHelper: {
command: config.helperCommand ?? ["paseo-workspace-helper"],
command: ["paseo-workspace-helper"],
env: {},
},
scriptTerminal: { kind: "direct-command", command: "/bin/sh", argsPrefix: ["-lc"] },
provider: { environment: "isolated", sharedHostProviders: new Set() },
async create(input) {
const response = await lifecycle("create", input.workspaceId, input);
if (response.type !== "state") throw new Error(`Invalid create response from ${runtimeId}`);
@@ -113,9 +122,15 @@ export function createCommandRuntime(
if (!modes.has("pty")) {
throw new Error(`Workspace runtime ${runtimeId} does not support PTY mode`);
}
return spawnCommandPty(runtimeId, config.command, input, config.options ?? {});
return spawnCommandPty(runtimeId, command, input, config.options ?? {}, runtimeInstanceId);
}
return spawnCommandProcess(runtimeId, config.command, input, config.options ?? {});
return spawnCommandProcess(
runtimeId,
command,
input,
config.options ?? {},
runtimeInstanceId,
);
},
async pause(workspaceId) {
const response = await lifecycle("pause", workspaceId);
@@ -137,6 +152,7 @@ export function createCommandRuntime(
["reconcile"],
encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
runtimeInstanceId,
workspaceIds,
options: config.options ?? {},
}),
@@ -149,7 +165,7 @@ export function createCommandRuntime(
};
async function runCommand(args: string[], stdin: string | undefined): Promise<string> {
const child = spawn(config.command[0], [...config.command.slice(1), ...args], {
const child = spawn(command[0], [...command.slice(1), ...args], {
env: commandEnvironment(),
shell: false,
stdio: ["pipe", "pipe", "pipe"],
@@ -199,6 +215,7 @@ function spawnCommandPty(
command: readonly [string, ...string[]],
input: WorkspaceDriverSpawnInput,
options: Readonly<Record<string, unknown>>,
runtimeInstanceId: string,
): WorkspacePtyProcess {
if (input.stdio.kind !== "pty") throw new Error("PTY spawn requires PTY stdio");
const execId = randomBytes(16).toString("hex");
@@ -303,6 +320,7 @@ function spawnCommandPty(
env: input.env,
purpose: input.purpose,
options,
runtimeInstanceId,
execId,
stdio: input.stdio,
}),
@@ -401,6 +419,7 @@ function spawnCommandPty(
input.workspaceId,
execId,
options,
runtimeInstanceId,
child,
wrapperClosedPromise,
);
@@ -425,6 +444,7 @@ async function forceKillCommandRuntime(
workspaceId: string,
execId: string,
options: Readonly<Record<string, unknown>>,
runtimeInstanceId: string,
wrapper: { pid?: number; kill(signal?: NodeJS.Signals): boolean },
wrapperClosed: Promise<void>,
): Promise<void> {
@@ -473,6 +493,7 @@ async function forceKillCommandRuntime(
signalCommand.stdin.end(
encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
runtimeInstanceId,
options,
}),
);
@@ -540,6 +561,7 @@ function spawnCommandProcess(
command: readonly [string, ...string[]],
input: WorkspaceDriverSpawnInput,
options: Readonly<Record<string, unknown>>,
runtimeInstanceId: string,
): WorkspacePipeProcess {
const execId = randomBytes(16).toString("hex");
const child = spawn(
@@ -622,6 +644,7 @@ function spawnCommandProcess(
env: input.env,
purpose: input.purpose,
options,
runtimeInstanceId,
execId,
stdio: input.stdio,
}),
@@ -650,6 +673,7 @@ function spawnCommandProcess(
input.workspaceId,
execId,
options,
runtimeInstanceId,
child,
wrapperClosedPromise,
);
@@ -693,6 +717,7 @@ function spawnCommandProcess(
input.workspaceId,
execId,
options,
runtimeInstanceId,
child,
wrapperClosedPromise,
);
@@ -736,3 +761,33 @@ function commandEnvironment(): NodeJS.ProcessEnv {
if (env.FORCE_COLOR !== undefined) delete env.NO_COLOR;
return env;
}
function resolveRuntimeCommand(
command: readonly [string, ...string[]],
packageResolutionBase: string,
pathResolutionBase: string,
): readonly [string, ...string[]] {
const executable = command[0];
const moduleRequire = createRequire(path.join(packageResolutionBase, "package.json"));
let resolved = executable;
if (executable.startsWith("@")) {
const packageJsonPath = moduleRequire.resolve(`${executable}/package.json`);
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
bin?: string | Record<string, string>;
};
const bin =
typeof packageJson.bin === "string"
? packageJson.bin
: packageJson.bin && Object.values(packageJson.bin)[0];
if (!bin) throw new Error(`Workspace runtime package has no executable: ${executable}`);
resolved = path.resolve(path.dirname(packageJsonPath), bin);
if ([".js", ".cjs", ".mjs"].includes(path.extname(resolved))) {
return [process.execPath, resolved, ...command.slice(1)];
}
} else if (path.isAbsolute(executable)) {
resolved = path.normalize(executable);
} else if (executable.startsWith(".")) {
resolved = path.resolve(pathResolutionBase, executable);
}
return [resolved, ...command.slice(1)];
}
@@ -105,6 +105,7 @@ export interface WorkspaceRuntimeDriver {
env: Readonly<Record<string, string>>;
};
readonly scriptTerminal: import("../index.js").WorkspaceScriptTerminal;
readonly provider: import("../index.js").WorkspaceRuntimeProviderCapability;
create(input: WorkspaceDriverCreateInput): Promise<WorkspaceDriverCreation>;
inspect(workspaceId: WorkspaceId): Promise<WorkspaceDriverInspection>;
spawn(input: WorkspaceDriverSpawnInput): Promise<WorkspaceDriverProcess>;
@@ -1,7 +1,7 @@
import type { Readable, Writable } from "node:stream";
import { createHash } from "node:crypto";
import { createRequire } from "node:module";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { WorkspaceFiles } from "../workspace-helper/index.js";
@@ -126,9 +126,15 @@ export interface BoundWorkspaceRuntime {
run(input: Omit<WorkspaceProcessInput, "workspaceId">): Promise<WorkspaceProcess>;
resolveCommand(command: string): Promise<string | null>;
readonly scriptTerminal: WorkspaceScriptTerminal;
readonly provider: WorkspaceRuntimeProviderCapability;
readonly files: WorkspaceFiles;
}
export interface WorkspaceRuntimeProviderCapability {
readonly environment: "inherit-sanitized-host" | "isolated";
readonly sharedHostProviders: ReadonlySet<string>;
}
export interface WorkspaceRuntimeService {
listRuntimes(): readonly WorkspaceRuntimeCatalogEntry[];
reconcile(): Promise<void>;
@@ -185,47 +191,22 @@ export interface ExternalWorkspaceRuntime {
type: "command";
label?: string;
command: readonly [string, ...string[]];
options?: Readonly<Record<string, unknown>>;
helperCommand?: readonly [string, ...string[]];
/** Explicit provider base environment for an isolated runtime. Host env is never inherited. */
providerEnvironment?: Readonly<Record<string, string>>;
options?: Readonly<Record<string, WorkspaceRuntimeJsonValue>>;
}
export interface DockerWorkspaceRuntime {
type: "docker";
image?: string;
bindMounts?: readonly DockerBindMount[];
providerEnvironment?: Readonly<Record<string, string>>;
enabled?: boolean;
}
export interface DockerBindMount {
source: string;
target: string;
readOnly: boolean;
}
export type WorkspaceRuntimeConfig = ExternalWorkspaceRuntime | DockerWorkspaceRuntime;
export type WorkspaceRuntimeConfig = ExternalWorkspaceRuntime;
export type WorkspaceRuntimeJsonValue =
| string
| number
| boolean
| null
| readonly WorkspaceRuntimeJsonValue[]
| { readonly [key: string]: WorkspaceRuntimeJsonValue };
export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore {
paseoHome: string;
worktreesRoot?: string;
externalRuntimes?: Readonly<Record<string, WorkspaceRuntimeConfig>>;
}
export const DEFAULT_DOCKER_WORKSPACE_IMAGE = "ghcr.io/getpaseo/workspace-runtime:latest";
const require = createRequire(import.meta.url);
function resolveDockerRuntimeCommand(): readonly [string, string] {
try {
return [process.execPath, require.resolve("@getpaseo/docker-workspace-runtime")];
} catch (error) {
throw new Error(
"Built-in Docker workspace runtime is unavailable; rebuild @getpaseo/docker-workspace-runtime before starting the daemon",
{ cause: error },
);
}
commandResolutionBase?: string;
}
export function createWorkspaceRuntimeService(
@@ -233,33 +214,20 @@ export function createWorkspaceRuntimeService(
): WorkspaceRuntimeService {
const hostGitObservations = createHostGitObservationOwner();
const configuredRuntimes = options.externalRuntimes ?? {};
const dockerConfig = configuredRuntimes.docker;
if (dockerConfig?.type === "command") {
throw new Error("Workspace runtime id is reserved: docker");
}
const dockerEnabled = dockerConfig?.enabled !== false;
const externalEntries = Object.entries(configuredRuntimes).filter(
([runtimeId]) => runtimeId !== "docker",
);
const externalEntries = Object.entries(configuredRuntimes);
const runtimeInstanceId = createHash("sha256").update(resolve(options.paseoHome)).digest("hex");
const externalDrivers = externalEntries.map(([runtimeId, config]) => {
if (runtimeId === "local" || runtimeId === "worktree") {
throw new Error(`Workspace runtime id is reserved: ${runtimeId}`);
}
if (config.type !== "command") {
throw new Error(`Docker runtime configuration is reserved for docker: ${runtimeId}`);
}
return createCommandRuntimeAdapter(runtimeId, config);
return createCommandRuntimeAdapter(
runtimeId,
config,
runtimeInstanceId,
options.commandResolutionBase ?? fileURLToPath(new URL(".", import.meta.url)),
options.paseoHome,
);
});
const dockerDriver = dockerEnabled
? createCommandRuntimeAdapter("docker", {
command: resolveDockerRuntimeCommand(),
options: {
image: dockerConfig?.image ?? DEFAULT_DOCKER_WORKSPACE_IMAGE,
owner: createHash("sha256").update(resolve(options.paseoHome)).digest("hex"),
...(dockerConfig?.bindMounts ? { bindMounts: dockerConfig.bindMounts } : {}),
},
})
: null;
const drivers = [
createLocalRuntime(options.paseoHome, hostGitObservations),
createWorktreeRuntime({
@@ -267,20 +235,18 @@ export function createWorkspaceRuntimeService(
worktreesRoot: options.worktreesRoot,
hostGitObservations,
}),
...(dockerDriver ? [dockerDriver] : []),
...externalDrivers,
];
const catalogMetadata = new Map<string, { builtin: boolean; label?: string }>([
["local", { builtin: true }],
["worktree", { builtin: true }],
...(dockerEnabled ? ([["docker", { builtin: true }]] as const) : []),
...externalEntries.map(
([runtimeId, config]) =>
[
runtimeId,
{
builtin: false,
...(config.type === "command" && config.label ? { label: config.label } : {}),
...(config.label ? { label: config.label } : {}),
},
] as const,
),
@@ -57,6 +57,10 @@ export function createLocalRuntime(
requiresGitProject: false,
workspaceHelper: hostWorkspaceHelper,
scriptTerminal: { kind: "persistent-shell" },
provider: {
environment: "inherit-sanitized-host",
sharedHostProviders: new Set(["opencode"]),
},
async create(input: WorkspaceDriverCreateInput) {
const existing = await inspect(input.workspaceId);
if (existing.status === "ready" || existing.status === "paused") {
@@ -261,6 +261,7 @@ export function createService(
resolveCommand: async (command) =>
(await requireFilesOwner(workspaceId)).resolveCommand(command),
scriptTerminal: driver.scriptTerminal,
provider: driver.provider,
files: bindFiles(workspaceId),
};
gitCommonObservations.bind(runtime, workspaceId, driver);
@@ -68,6 +68,10 @@ export function createWorktreeRuntime(options: {
requiresGitProject: true,
workspaceHelper: hostWorkspaceHelper,
scriptTerminal: { kind: "persistent-shell" },
provider: {
environment: "inherit-sanitized-host",
sharedHostProviders: new Set(["opencode"]),
},
async create(input: WorkspaceDriverCreateInput) {
const existing = await inspect(input.workspaceId);
if (existing.status === "ready" || existing.status === "paused") {
@@ -0,0 +1,48 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url));
test("core owns only generic command runtime registration", () => {
const serverPackage = packageJson("packages/server/package.json");
expect(serverPackage.dependencies).not.toHaveProperty("@getpaseo/docker-workspace-runtime");
const rootPackage = packageJson("package.json");
expect(rootPackage.scripts?.["build:server-deps"]).not.toContain("docker-workspace-runtime");
expect(rootPackage.scripts?.["build:server-deps:clean"]).not.toContain(
"docker-workspace-runtime",
);
for (const relativePath of [
"packages/server/src/server/workspace-runtime/index.ts",
"packages/server/src/server/persisted-config.ts",
"packages/server/src/server/bootstrap.ts",
"packages/app/src/new-workspace-runtime/model.ts",
]) {
const source = readFileSync(path.join(repositoryRoot, relativePath), "utf8");
expect(source, relativePath).not.toContain("@getpaseo/docker-workspace-runtime");
expect(source, relativePath).not.toMatch(/type:\s*["']docker["']/u);
}
});
test("Docker's bundled registration exists only at distribution composition roots", () => {
const packageName = "@getpaseo/docker-workspace-runtime";
for (const relativePath of [
"packages/cli/src/commands/daemon/local-daemon.ts",
"packages/desktop/src/daemon/daemon-manager.ts",
]) {
expect(readFileSync(path.join(repositoryRoot, relativePath), "utf8"), relativePath).toContain(
packageName,
);
}
});
function packageJson(relativePath: string): {
dependencies?: Record<string, string>;
scripts?: Record<string, string>;
} {
return JSON.parse(readFileSync(path.join(repositoryRoot, relativePath), "utf8"));
}
@@ -3,7 +3,7 @@ import { existsSync, watch } from "node:fs";
import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, expect, test, vi } from "vitest";
@@ -32,10 +32,10 @@ test("a trusted registered command is selected and receives secret launch data o
...fixture.createInput,
setup: [{ argv: ["/bin/sh", "-c", "printf setup > setup-purpose.txt"], env: {} }],
});
const setupLaunch = JSON.parse(
const materializationLaunch = JSON.parse(
await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"),
) as { purpose: unknown };
expect(setupLaunch.purpose).toEqual({ kind: "setup" });
expect(materializationLaunch.purpose).toEqual({ kind: "workspace-helper" });
const secret = "secret-shaped-workload-value";
const process = await fixture.service.run({
workspaceId: fixture.workspaceId,
@@ -74,7 +74,6 @@ test("the fixture executable receives provider probe purpose through the strict
await fixture.service.create({
...fixture.createInput,
purpose: "provider-probe",
setupFromPaseoConfig: false,
});
const [stateFile] = await readdir(fixture.stateDirectory);
const state = JSON.parse(
@@ -92,6 +91,115 @@ test("the fixture executable receives provider probe purpose through the strict
await fixture.service.destroy(fixture.workspaceId);
});
test("resolves package, filesystem, and PATH runtime executables without shell parsing", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-command-resolution-"));
cleanupRoots.push(root);
const source = path.join(root, "source");
await mkdir(source);
await symlink(fixtureExecutable, path.join(root, "runtime.mjs"));
const runtimeIds = new Map<string, string>();
const commands = {
package: ["@getpaseo/fixture-workspace-runtime"],
filesystem: [fixtureExecutable],
relative: ["./runtime.mjs"],
environment: ["paseo-fixture-workspace-runtime"],
} as const;
const service = createWorkspaceRuntimeService({
paseoHome: root,
resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null,
persistRuntimeId: async (workspaceId, runtimeId) => void runtimeIds.set(workspaceId, runtimeId),
beginWorkspaceDeletion: async () => {},
removeWorkspaceRecord: async (workspaceId) => void runtimeIds.delete(workspaceId),
externalRuntimes: Object.fromEntries(
Object.entries(commands).map(([runtimeId, command]) => [
runtimeId,
{
type: "command" as const,
command,
options: { stateDirectory: path.join(root, runtimeId) },
},
]),
),
});
await Promise.all(Object.keys(commands).map((runtimeId) => mkdir(path.join(root, runtimeId))));
for (const runtimeId of Object.keys(commands)) {
const workspaceId = `resolution-${runtimeId}`;
await expect(
service.create({
workspaceId,
runtimeId,
project: { id: runtimeId, source: { kind: "host-directory", path: source } },
placement: { kind: "existing" },
}),
).resolves.toMatchObject({ workspaceId, runtimeId });
await service.destroy(workspaceId);
}
});
test("launches JavaScript package bins with Node and preserves configured argv", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-command-package-bin-"));
cleanupRoots.push(root);
const source = path.join(root, "source");
const packageRoot = path.join(root, "node_modules", "@fixture", "runtime");
const argvFile = path.join(root, "package-bin-argv.json");
await Promise.all([mkdir(source), mkdir(packageRoot, { recursive: true })]);
await writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "@fixture/runtime", type: "module", bin: "runtime.js" }),
);
await writeFile(
path.join(packageRoot, "runtime.js"),
[
"import { appendFileSync } from 'node:fs';",
"appendFileSync(process.argv[2], `${JSON.stringify(process.argv.slice(2))}\\n`);",
`await import(${JSON.stringify(pathToFileURL(fixtureExecutable).href)});`,
].join("\n"),
{ mode: 0o644 },
);
const runtimeIds = new Map<string, string>();
const service = createWorkspaceRuntimeService({
paseoHome: root,
commandResolutionBase: root,
resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null,
persistRuntimeId: async (workspaceId, runtimeId) => void runtimeIds.set(workspaceId, runtimeId),
beginWorkspaceDeletion: async () => {},
removeWorkspaceRecord: async (workspaceId) => void runtimeIds.delete(workspaceId),
externalRuntimes: {
package: {
type: "command",
command: ["@fixture/runtime", argvFile, "--modes", "pipes"],
options: { stateDirectory: path.join(root, "state") },
},
},
});
await service.create({
workspaceId: "package-bin",
runtimeId: "package",
project: { id: "package", source: { kind: "host-directory", path: source } },
placement: { kind: "existing" },
});
const launches = (await readFile(argvFile, "utf8"))
.trim()
.split("\n")
.map((line) => JSON.parse(line) as string[]);
expect(
launches.every(
(argv) => argv.slice(0, 3).join("\0") === [argvFile, "--modes", "pipes"].join("\0"),
),
).toBe(true);
expect(launches).toContainEqual([
argvFile,
"--modes",
"pipes",
"create",
"--workspace-id",
"package-bin",
]);
await service.destroy("package-bin");
});
test("equal display cwd values never share external runtime execution, files, Git, or caches", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-command-runtime-equal-display-"));
cleanupRoots.push(root);
@@ -120,7 +228,6 @@ test("equal display cwd values never share external runtime execution, files, Gi
{
type: "command" as const,
command: [processExecPath(), fixtureExecutable] as const,
helperCommand: [processExecPath(), helperExecutable] as const,
options: {
stateDirectory: stateDirectories[index],
displayCwd: "/workspace",
@@ -759,7 +866,7 @@ async function createFixture(
withBarrier = false,
modes: "pipes" | "pty" = "pty",
runtimeOptions: Readonly<Record<string, unknown>> = {},
helperCommand?: (root: string) => readonly [string, ...string[]],
fixtureHelperCommand?: (root: string) => readonly [string, ...string[]],
) {
const root = await mkdtemp(path.join(tmpdir(), `paseo-command-runtime-${name}-`));
cleanupRoots.push(root);
@@ -794,9 +901,9 @@ async function createFixture(
? []
: ["--protocol-version", String(runtimeOptions.describeProtocolVersion)]),
],
helperCommand: helperCommand?.(root) ?? [processExecPath(), helperExecutable],
options: {
stateDirectory,
...(fixtureHelperCommand ? { fixtureHelperCommand: fixtureHelperCommand(root) } : {}),
...runtimeOptions,
...(withBarrier ? { inspectBarrierDirectory: barrierDirectory } : {}),
},
@@ -16,7 +16,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { beforeAll, expect, onTestFinished, test, vi } from "vitest";
import { expect, onTestFinished, test, vi } from "vitest";
import { createWorkspaceRuntimeService } from "./index.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
@@ -34,19 +34,20 @@ import {
const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url));
const dockerRuntimeRoot = path.join(repositoryRoot, "packages/docker-workspace-runtime");
const runtimeExecutable = path.join(dockerRuntimeRoot, "dist/index.js");
const image = "paseo-workspace-runtime-slice1:test";
const image = process.env.E2E_DOCKER_WORKSPACE_IMAGE ?? "paseo-workspace-runtime:phase3-qa";
const fixtureAgent = fileURLToPath(
new URL("../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url),
);
const run = promisify(execFile);
beforeAll(() => {
execFileSync(
"docker",
["build", "-t", image, "-f", path.join(dockerRuntimeRoot, "Dockerfile"), repositoryRoot],
{ stdio: "pipe" },
);
}, 120_000);
function dockerRegistration(options: Readonly<Record<string, unknown>> = {}) {
return {
type: "command" as const,
label: "Docker",
command: ["@getpaseo/docker-workspace-runtime"] as const,
options: { image, ...options },
};
}
async function collect(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
@@ -57,6 +58,8 @@ async function collect(stream: NodeJS.ReadableStream): Promise<string> {
}
test("the command runtime owns Docker workspace materialization, pipes, and lifecycle", async () => {
const originalHostOnlySetup = process.env.PASEO_SETUP_CUSTOM_INHERITED;
process.env.PASEO_SETUP_CUSTOM_INHERITED = "must-not-enter-container";
const fixture = await dockerTestFixture("paseo-runtime-docker-");
const root = fixture.root;
const repo = await createRepository(root);
@@ -73,10 +76,7 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
runtimeIds.delete(workspaceId);
},
externalRuntimes: {
docker: {
type: "docker",
image,
},
docker: dockerRegistration(),
},
};
const service = createWorkspaceRuntimeService(options);
@@ -94,7 +94,12 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
placement: { kind: "existing" },
});
expect(workspace).toEqual({ workspaceId, runtimeId: "docker", cwd: "/workspace" });
expect(workspace).toEqual({
workspaceId,
runtimeId: "docker",
cwd: "/workspace",
materializedFreshContent: true,
});
await expect(service.inspect(workspaceId)).resolves.toEqual({
status: "ready",
cwd: "/workspace",
@@ -103,6 +108,27 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
`Workspace has no host-visible path: ${workspaceId}`,
);
const setupEnvironment = await service.run({
workspaceId,
argv: [
"/usr/local/bin/node",
"-e",
"process.stdout.write(JSON.stringify({source:process.env.PASEO_SOURCE_CHECKOUT_PATH,root:process.env.PASEO_ROOT_PATH,worktree:process.env.PASEO_WORKTREE_PATH,branch:process.env.PASEO_BRANCH_NAME,port:process.env.PASEO_WORKTREE_PORT,path:process.env.PATH,hostOnly:process.env.PASEO_SETUP_CUSTOM_INHERITED}))",
],
env: {},
purpose: { kind: "setup" },
});
setupEnvironment.stdin.end();
await expect(collect(setupEnvironment.stdout).then(JSON.parse)).resolves.toEqual({
source: ".",
root: ".",
worktree: ".",
branch: "",
port: expect.stringMatching(/^\d+$/u),
path: "/usr/local/bin:/usr/bin:/bin",
});
await expect(setupEnvironment.exited).resolves.toEqual({ code: 0, signal: null });
for (const [name, value] of [
["user.email", "test@example.com"],
["user.name", "Paseo Test"],
@@ -363,6 +389,8 @@ test("the command runtime owns Docker workspace materialization, pipes, and life
}),
).rejects.toThrow(`Workspace runtime is not selected: ${workspaceId}`);
} finally {
if (originalHostOnlySetup === undefined) delete process.env.PASEO_SETUP_CUSTOM_INHERITED;
else process.env.PASEO_SETUP_CUSTOM_INHERITED = originalHostOnlySetup;
await service.destroy(workspaceId).catch(() => undefined);
}
}, 180_000);
@@ -385,7 +413,7 @@ test("production archive and deletion reap Docker runtime processes and owned re
runtimeIds.delete(id);
archivedWorkspaceIds.delete(id);
},
externalRuntimes: { docker: { type: "docker", image } },
externalRuntimes: { docker: dockerRegistration() },
});
onTestFinished(async () => {
@@ -461,10 +489,7 @@ test("Docker buffers immediate pipe signals until the workload identity is autho
runtimeIds.delete(id);
},
externalRuntimes: {
docker: {
type: "docker",
image,
},
docker: dockerRegistration(),
},
});
const originalPath = process.env.PATH;
@@ -567,10 +592,7 @@ test("a selected Docker service port script executes in the container and leaves
runtimeIds.delete(id);
},
externalRuntimes: {
docker: {
type: "docker",
image,
},
docker: dockerRegistration(),
},
});
try {
@@ -617,15 +639,8 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi
const paseoHomeRoot = path.join(root, "daemon-home");
const paseoHome = path.join(paseoHomeRoot, ".paseo");
await mkdir(paseoHome, { recursive: true });
const runtimeOwner = createHash("sha256").update(path.resolve(paseoHome)).digest("hex");
const runtimeConfig = { type: "docker" as const, image };
const configuredRuntimeConfig = {
type: "command" as const,
command: [process.execPath, runtimeExecutable] as const,
options: { image, owner: runtimeOwner },
providerEnvironment: { PASEO_DAEMON_ONLY_PROVIDER_ENV: "runtime-configured" },
};
const runtimeConfigs = { docker: runtimeConfig, "docker-configured": configuredRuntimeConfig };
const runtimeConfig = dockerRegistration();
const runtimeConfigs = { docker: runtimeConfig };
const workspaceIds = [`docker-daemon-a-${Date.now()}`, `docker-daemon-b-${Date.now()}`];
for (const workspaceId of workspaceIds) fixture.workspace(workspaceId);
const hostProbeMarker = path.join(root, "host-provider-probed.txt");
@@ -637,7 +652,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi
await chmod(hostOnlyProvider, 0o755);
const selectedRuntimeIds = new Map([
[workspaceIds[0]!, "docker"],
[workspaceIds[1]!, "docker-configured"],
[workspaceIds[1]!, "docker"],
]);
const seedRuntime = createWorkspaceRuntimeService({
paseoHome,
@@ -674,7 +689,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi
);
for (const workspaceId of workspaceIds) {
const runtimeId = selectedRuntimeIds.get(workspaceId)!;
await seedRuntime.create({
const created = await seedRuntime.create({
workspaceId,
runtimeId,
project: { id: "docker-daemon-project", source: { kind: "host-directory", path: repo } },
@@ -684,7 +699,8 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi
createPersistedWorkspaceRecord({
workspaceId,
projectId: "docker-daemon-project",
cwd: repo,
cwd: created.cwd,
hostVisiblePath: created.hostVisiblePath ?? null,
kind: "local_checkout",
displayName: workspaceId,
runtime: { runtimeId },
@@ -806,25 +822,6 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi
workspaceId,
);
expect(new TextDecoder().decode(isolatedEnvironment.bytes)).toBe("<absent>\n");
const configuredAgent = await publicClient.createAgent({
provider: "docker-fixture",
model: "fixture-model-v1",
cwd: publicCwds.get(workspaceIds[1]!)!,
workspaceId: workspaceIds[1],
title: "Configured Docker public fake provider",
});
await publicClient.sendMessage(configuredAgent.id, "configured public docker edit");
await expect(publicClient.waitForFinish(configuredAgent.id, 30_000)).resolves.toMatchObject({
status: "idle",
});
const configuredEnvironment = await publicClient.readFile(
publicCwds.get(workspaceIds[1]!)!,
"stdio-agent-env.txt",
undefined,
workspaceIds[1],
);
expect(new TextDecoder().decode(configuredEnvironment.bytes)).toBe("runtime-configured\n");
} finally {
await publicClient.close().catch(() => undefined);
await daemon.close();
@@ -866,7 +863,7 @@ test("Docker probe pause survives restart and source invalidation rotates the pr
cleanup: false,
mcpEnabled: false,
agentClients: {},
workspaceRuntimes: { docker: { type: "docker" as const, image } },
workspaceRuntimes: { docker: dockerRegistration() },
providerOverrides: {
"docker-fixture": {
extends: "acp" as const,
@@ -967,6 +964,14 @@ test("persisted Git sources materialize default and pinned revisions through Doc
path.join(sourceRepository, "packages", "app", "provider-model.txt"),
"fixture-model-pinned\n",
);
await writeFile(
path.join(sourceRepository, "packages", "app", "paseo.json"),
JSON.stringify({
worktree: {
setup: ["node -e \"process.stdout.write('docker-setup-cwd:'+process.cwd())\""],
},
}),
);
execFileSync("git", ["add", "."], { cwd: sourceRepository });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pinned state"], {
cwd: sourceRepository,
@@ -1043,11 +1048,9 @@ test("persisted Git sources materialize default and pinned revisions through Doc
},
},
workspaceRuntimes: {
docker: {
type: "docker",
image,
docker: dockerRegistration({
bindMounts: [{ source: bareRepository, target: "/git/source.git", readOnly: true }],
},
}),
},
});
const client = new DaemonClient({
@@ -1059,6 +1062,11 @@ test("persisted Git sources materialize default and pinned revisions through Doc
try {
await client.connect();
const publicMessages: Parameters<Parameters<typeof client.subscribeRawMessages>[0]>[0][] = [];
const unsubscribeMessages = client.subscribeRawMessages((message) =>
publicMessages.push(message),
);
onTestFinished(unsubscribeMessages);
const defaultProbe = await client.ensureWorkspaceRuntimeProbe({
projectId: projectIds[0],
runtimeId: "docker",
@@ -1086,6 +1094,26 @@ test("persisted Git sources materialize default and pinned revisions through Doc
throw new Error(defaultWorkspace.error ?? "Default Git-source workspace creation failed");
}
fixture.workspace(defaultWorkspace.workspace.id);
await expect
.poll(() => client.fetchWorkspaceSetupStatus(defaultWorkspace.workspace!.id), {
timeout: 30_000,
})
.toMatchObject({ snapshot: { status: "completed", error: null } });
const setupProgress = publicMessages.filter(
(message) =>
message.type === "workspace_setup_progress" &&
message.payload.workspaceId === defaultWorkspace.workspace?.id,
);
expect(setupProgress).not.toHaveLength(0);
for (const message of setupProgress) {
if (message.type !== "workspace_setup_progress") continue;
expect(message.payload.detail.worktreePath).toBe(workspaceRoot);
expect(message.payload.detail.worktreePath).not.toContain(decoyDefault);
for (const command of message.payload.detail.commands) {
expect(command.cwd).toBe(workspaceRoot);
expect(command.cwd).not.toContain(decoyDefault);
}
}
const defaultFile = await client.readFile(
workspaceRoot,
"source-state.txt",
@@ -1139,10 +1167,7 @@ test("Docker creation validates roots and rolls back when runtime selection cann
throw new Error("persistence failed");
},
externalRuntimes: {
docker: {
type: "docker",
image,
},
docker: dockerRegistration(),
},
});
await expect(
@@ -1192,10 +1217,7 @@ test("Docker rejects a committed symlink cwd that resolves outside the selected
runtimeIds.delete(id);
},
externalRuntimes: {
docker: {
type: "docker",
image,
},
docker: dockerRegistration(),
},
});
@@ -1249,10 +1271,7 @@ test("the Docker runtime never adopts or destroys an unowned deterministic resou
runtimeIds.delete(id);
},
externalRuntimes: {
docker: {
type: "docker",
image,
},
docker: dockerRegistration(),
},
});
@@ -1436,7 +1455,6 @@ test("Docker creation revalidates a concurrently created foreign volume before m
const resourceName = dockerResourceName(workspaceId);
fixture.workspace(workspaceId);
const paseoHome = path.join(fixture.root, "owner-home");
const owner = dockerRuntimeOwner(paseoHome);
const foreignOwner = dockerRuntimeOwner(path.join(fixture.root, "foreign-home"));
const attemptedMountPath = path.join(fixture.root, "foreign-volume-mounted");
const wrapperDirectory = await createDockerRaceWrapper({
@@ -1447,7 +1465,7 @@ test("Docker creation revalidates a concurrently created foreign volume before m
attemptedMountPath,
race: "volume",
});
const service = createCommandDockerService({ paseoHome, owner, wrapperDirectory });
const service = createCommandDockerService({ paseoHome, wrapperDirectory });
await expect(
service.create({
@@ -1475,7 +1493,6 @@ test("Docker creation revalidates a concurrently created foreign container befor
const resourceName = dockerResourceName(workspaceId);
fixture.workspace(workspaceId);
const paseoHome = path.join(fixture.root, "owner-home");
const owner = dockerRuntimeOwner(paseoHome);
const foreignOwner = dockerRuntimeOwner(path.join(fixture.root, "foreign-home"));
const attemptedMutationPath = path.join(fixture.root, "foreign-container-started");
const wrapperDirectory = await createDockerRaceWrapper({
@@ -1486,7 +1503,7 @@ test("Docker creation revalidates a concurrently created foreign container befor
attemptedMutationPath,
race: "container",
});
const service = createCommandDockerService({ paseoHome, owner, wrapperDirectory });
const service = createCommandDockerService({ paseoHome, wrapperDirectory });
await expect(
service.create({
@@ -1524,9 +1541,7 @@ test("trusted Docker configuration mounts one runtime credential file read-only"
runtimeIds.delete(workspaceId);
},
externalRuntimes: {
docker: {
type: "docker",
image,
docker: dockerRegistration({
bindMounts: [
{
source: mountedFile,
@@ -1534,7 +1549,7 @@ test("trusted Docker configuration mounts one runtime credential file read-only"
readOnly: true,
},
],
},
}),
},
});
const workspaceId = `docker-bind-${Date.now()}`;
@@ -1638,10 +1653,7 @@ test("Docker reconciliation removes only orphaned resources with deterministic n
removeWorkspaceRecord: async () => {},
listRuntimeRecords: async () => [],
externalRuntimes: {
docker: {
type: "docker",
image,
},
docker: dockerRegistration(),
},
});
@@ -1670,7 +1682,7 @@ async function dockerTestFixture(prefix: string): Promise<{
workspace(workspaceId: string): void;
volume(name: string): void;
}> {
const root = await mkdtemp(path.join(tmpdir(), prefix));
let root: string | null = null;
const containers = new Set<string>();
const volumes = new Set<string>();
const workspaceIds = new Set<string>();
@@ -1696,13 +1708,14 @@ async function dockerTestFixture(prefix: string): Promise<{
);
}
const leakedProcesses = dockerTestProcessPids(workspaceIds);
await rm(root, { recursive: true, force: true });
if (root) await rm(root, { recursive: true, force: true });
if (leakedContainers.length || leakedVolumes.length || leakedProcesses.length) {
throw new Error(
`Docker test leaked containers=${leakedContainers.join(",")} volumes=${leakedVolumes.join(",")} processes=${leakedProcesses.join(",")}`,
);
}
}, 30_000);
root = await mkdtemp(path.join(tmpdir(), prefix));
return {
root,
workspace(workspaceId) {
@@ -1742,15 +1755,11 @@ function createDockerService(paseoHome: string) {
removeWorkspaceRecord: async (workspaceId) => {
runtimeIds.delete(workspaceId);
},
externalRuntimes: { docker: { type: "docker", image } },
externalRuntimes: { docker: dockerRegistration() },
});
}
function createCommandDockerService(input: {
paseoHome: string;
owner: string;
wrapperDirectory: string;
}) {
function createCommandDockerService(input: { paseoHome: string; wrapperDirectory: string }) {
const runtimeIds = new Map<string, string>();
return createWorkspaceRuntimeService({
paseoHome: input.paseoHome,
@@ -1771,7 +1780,7 @@ function createCommandDockerService(input: {
process.execPath,
runtimeExecutable,
],
options: { image, owner: input.owner },
options: { image },
},
},
});
@@ -20,9 +20,6 @@ const runtimeContractIds = ["local", "worktree", "fixture"] as const;
const fixtureRuntimeExecutable = fileURLToPath(
new URL("../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
);
const helperExecutable = fileURLToPath(
new URL("../workspace-helper/executable.mjs", import.meta.url),
);
afterEach(async () => {
await Promise.all(
@@ -39,7 +36,6 @@ test("lists built-in and configured runtimes in registration order", async () =>
type: "command",
label: "Fixture",
command: [process.execPath, fixtureRuntimeExecutable],
helperCommand: [process.execPath, helperExecutable],
},
},
resolveRuntimeId: async () => null,
@@ -49,7 +45,6 @@ test("lists built-in and configured runtimes in registration order", async () =>
expect(service.listRuntimes()).toEqual([
{ runtimeId: "local", builtin: true, requiresGitProject: false },
{ runtimeId: "worktree", builtin: true, requiresGitProject: true },
{ runtimeId: "docker", builtin: true, requiresGitProject: true },
{
runtimeId: "fixture",
builtin: false,
@@ -59,17 +54,15 @@ test("lists built-in and configured runtimes in registration order", async () =>
]);
});
test("omits the built-in Docker runtime when its registration is disabled", async () => {
const root = await temporaryRoot("catalog-docker-disabled");
test("has no implicit command runtime registration in core", async () => {
const root = await temporaryRoot("catalog-no-implicit-runtime");
const service = createWorkspaceRuntimeService({
paseoHome: path.join(root, "home"),
externalRuntimes: {
docker: { type: "docker", enabled: false },
fixture: {
type: "command",
label: "Fixture",
command: [process.execPath, fixtureRuntimeExecutable],
helperCommand: [process.execPath, helperExecutable],
},
},
resolveRuntimeId: async () => null,
@@ -88,9 +81,12 @@ test("omits the built-in Docker runtime when its registration is disabled", asyn
]);
await expect(
service.create({
workspaceId: "disabled-docker",
workspaceId: "unregistered-command-runtime",
runtimeId: "docker",
project: { id: "disabled-docker", source: { kind: "host-directory", path: root } },
project: {
id: "unregistered-command-runtime",
source: { kind: "host-directory", path: root },
},
placement: { kind: "existing" },
}),
).rejects.toThrow("Workspace runtime is not registered: docker");
@@ -151,6 +147,7 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId)
expect(Object.keys(runtime).sort()).toEqual([
"files",
"provider",
"resolveCommand",
"run",
"scriptTerminal",
@@ -796,7 +793,6 @@ async function createFixture(
fixture: {
type: "command",
command: [process.execPath, fixtureRuntimeExecutable],
helperCommand: [process.execPath, helperExecutable],
options: { stateDirectory: fixtureStateDirectory },
},
}
@@ -291,7 +291,7 @@
"minLength": 1
},
"additionalProperties": {
"oneOf": [
"anyOf": [
{
"type": "object",
"properties": {
@@ -312,27 +312,12 @@
}
},
"options": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"providerEnvironment": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"helperCommand": {
"minItems": 1,
"type": "array",
"items": {
"type": "string",
"minLength": 1
"$ref": "#/definitions/__schema0"
}
}
},
@@ -340,31 +325,7 @@
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "docker"
},
"image": {
"type": "string",
"minLength": 1
},
"providerEnvironment": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"enabled": {
"type": "boolean"
}
},
"required": ["type"],
"additionalProperties": false
"type": "null"
}
]
}
@@ -727,5 +688,38 @@
}
},
"additionalProperties": false,
"definitions": {
"__schema0": {
"anyOf": [
{
"type": "string"
},
{
"type": "number"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "array",
"items": {
"$ref": "#/definitions/__schema0"
}
},
{
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"$ref": "#/definitions/__schema0"
}
}
]
}
},
"title": "PaseoConfigV1"
}
@@ -20,11 +20,11 @@ reconcile
`describe` writes `CommandRuntimeDescribeResponse` JSON to stdout. Lifecycle commands read one `CommandRuntimeLifecycleRequest` JSON value from stdin and write one `CommandRuntimeLifecycleResponse` JSON value to stdout. `create` and `resume` return `state`; `inspect` returns `inspection`; `pause`, `destroy`, and `reconcile` return `ok`. Diagnostics go to stderr. A non-zero wrapper exit means the operation failed.
Paseo sends options from trusted daemon configuration. Secrets never belong in argv or temporary request files. The runtime must reject any `protocolVersion` other than `1` with a clear stderr diagnostic and non-zero exit. Every public v1 object schema is strict at every nested object boundary, so unknown keys—including private authority—fail instead of being stripped. Extensibility belongs only in the explicit `options`, workload `env`, and lifecycle-environment maps. The optional create `purpose: "provider-probe"` field and create-only response field `materializedFreshContent` were added before external v1 adoption. Runtimes that optimize probe placement may use `purpose`; omission retains ordinary workspace creation. Every `create` response must set `materializedFreshContent`: `true` only when that call created fresh workspace content/resources on which repo setup is permitted, and `false` when it adopted or reused existing content. `resume` state responses omit it.
Paseo sends options from trusted daemon configuration and a stable `runtimeInstanceId` for shared-resource ownership. Runtime implementations decide how that opaque instance token maps to their resource system. Secrets never belong in argv or temporary request files. The runtime must reject any `protocolVersion` other than `1` with a clear stderr diagnostic and non-zero exit. Every public v1 object schema is strict at every nested object boundary, so unknown keys fail instead of being stripped. Extensibility belongs only in the explicit `options`, workload `env`, and lifecycle-environment maps. The optional create `purpose: "provider-probe"` field and create-only response field `materializedFreshContent` were added before external v1 adoption. Runtimes that optimize probe placement may use `purpose`; omission retains ordinary workspace creation. Every `create` response must set `materializedFreshContent`: `true` only when that call created fresh workspace content/resources on which repo setup is permitted, and `false` when it adopted or reused existing content. `resume` state responses omit it.
`project.source` is either a host directory or a Git URL plus a required revision string and optional subdirectory. An empty Git revision selects the remote's default state. Paseo derives this source before invoking the runtime; `placement.cwd` and `hostVisiblePath` never override it. A `provider-probe` create must expose the same runtime environment as a user workspace but must not execute repository setup. The checked-in JSON example uses this purpose so wrappers can lock its exact bytes.
The built-in Docker adapter owns image selection, project volumes, and configured bind mounts. Bind mounts are daemon configuration, not contract fields: a wrapper receives only its trusted `options`. Keep runtime-specific mount, container, VM, or supervisor authority out of lifecycle state and placement.
A runtime owns the meaning of its `options`. Keep runtime-specific mount, container, VM, or supervisor authority out of lifecycle state and placement.
## Exec file descriptors
@@ -42,7 +42,7 @@ The authoritative fd 4 `exit` preserves the workload code or terminating signal;
Paseo addresses only `workspaceId`. The runtime keeps physical placement private and validates the optional workspace-relative `cwd` once, including traversal and symlink escapes; omission means workspace root. It executes `argv` directly, never as an implicit shell string, with exactly the provided workload environment. Do not inherit the wrapper or daemon environment. Lifecycle state contains no root, revision, execution domain, container name, or supervisor state. Public placement is descriptive compatibility data only: `cwd` is never execution authority, and `hostVisiblePath` is only an optional host-integration capability.
The configured runtime environment must provide the compatible `paseo-workspace-helper` executable, or the daemon configuration must set `helperCommand`. Paseo launches it as an ordinary workload with purpose `workspace-helper`, already rooted at the private workspace placement. The helper receives only relative `--path` values and uses its process cwd (`.`) as confinement authority; it has no `--root` option. Runtime authors do not implement file, watch, Git, provider, script, or agent APIs: the helper handles structured files and watching, while Paseo runs Git, providers, and scripts through `exec`.
Every runtime must provide the compatible `paseo-workspace-helper` executable on its workload `PATH`. Paseo launches it as an ordinary workload with purpose `workspace-helper`, already rooted at the private workspace placement. The helper receives only relative `--path` values and uses its process cwd (`.`) as confinement authority; it has no `--root` option. Runtime authors do not implement file, watch, Git, provider, script, or agent APIs: the helper handles structured files and watching, while Paseo runs Git, providers, and scripts through `exec`.
## Compatibility
@@ -6,6 +6,7 @@
},
"lifecycleRequest": {
"protocolVersion": 1,
"runtimeInstanceId": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"input": {
"workspaceId": "workspace-01",
"project": {
@@ -35,6 +36,7 @@
"pipeSpawnControl": {
"type": "spawn",
"protocolVersion": 1,
"runtimeInstanceId": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"argv": ["/bin/sh", "-c", "printf hello"],
"cwd": "packages/app",
"env": { "PATH": "/usr/bin:/bin" },
@@ -46,6 +48,7 @@
"ptySpawnControl": {
"type": "spawn",
"protocolVersion": 1,
"runtimeInstanceId": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"argv": ["/bin/sh", "-c", "printf hello"],
"cwd": "packages/app",
"env": { "PATH": "/usr/bin:/bin" },
@@ -82,10 +85,10 @@
},
"bytes": {
"describeResponse": "{\"protocolVersion\":1,\"modes\":[\"pipes\",\"pty\"],\"reconcile\":true}\n",
"lifecycleRequest": "{\"protocolVersion\":1,\"input\":{\"workspaceId\":\"workspace-01\",\"project\":{\"projectId\":\"project-01\",\"source\":{\"kind\":\"git\",\"url\":\"https://example.test/acme/project.git\",\"revision\":\"main\",\"subdirectory\":\"packages/app\"}},\"placement\":{\"kind\":\"existing\"},\"purpose\":\"provider-probe\"},\"options\":{}}\n",
"lifecycleRequest": "{\"protocolVersion\":1,\"runtimeInstanceId\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"input\":{\"workspaceId\":\"workspace-01\",\"project\":{\"projectId\":\"project-01\",\"source\":{\"kind\":\"git\",\"url\":\"https://example.test/acme/project.git\",\"revision\":\"main\",\"subdirectory\":\"packages/app\"}},\"placement\":{\"kind\":\"existing\"},\"purpose\":\"provider-probe\"},\"options\":{}}\n",
"lifecycleResponse": "{\"type\":\"state\",\"protocolVersion\":1,\"state\":{\"workspaceId\":\"workspace-01\",\"lifecycle\":\"ready\"},\"placement\":{\"cwd\":\"/workspace\"},\"materializedFreshContent\":true}\n",
"pipeSpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pipe-01\",\"stdio\":{\"kind\":\"pipes\"}}\n",
"ptySpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pty-01\",\"stdio\":{\"kind\":\"pty\",\"rows\":24,\"cols\":80,\"term\":\"xterm-256color\"}}\n",
"pipeSpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"runtimeInstanceId\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pipe-01\",\"stdio\":{\"kind\":\"pipes\"}}\n",
"ptySpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"runtimeInstanceId\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pty-01\",\"stdio\":{\"kind\":\"pty\",\"rows\":24,\"cols\":80,\"term\":\"xterm-256color\"}}\n",
"resizeControl": "{\"type\":\"resize\",\"protocolVersion\":1,\"id\":1,\"rows\":40,\"cols\":120}\n",
"signalControl": "{\"type\":\"signal\",\"protocolVersion\":1,\"signal\":\"SIGTERM\"}\n",
"startedEvent": "{\"type\":\"started\",\"protocolVersion\":1}\n",
@@ -4,7 +4,7 @@ export const COMMAND_RUNTIME_PROTOCOL_VERSION = 1 as const;
const ProtocolVersionSchema = z.literal(COMMAND_RUNTIME_PROTOCOL_VERSION);
const EnvironmentSchema = z.record(z.string(), z.string());
const OptionsSchema = z.record(z.string(), z.unknown());
const OptionsSchema = z.record(z.string(), z.json());
const CommandSchema = z.array(z.string()).min(1);
export const CommandRuntimeProjectSourceSchema = z.discriminatedUnion("kind", [
@@ -92,6 +92,7 @@ export const CommandRuntimeCreateInputSchema = z
export const CommandRuntimeLifecycleRequestSchema = z
.object({
protocolVersion: ProtocolVersionSchema,
runtimeInstanceId: z.string().min(1),
input: CommandRuntimeCreateInputSchema.optional(),
options: OptionsSchema,
workspaceIds: z.array(z.string()).optional(),
@@ -187,6 +188,7 @@ export const CommandRuntimeSpawnEnvelopeSchema = z
.object({
type: z.literal("spawn"),
protocolVersion: ProtocolVersionSchema,
runtimeInstanceId: z.string().min(1),
argv: CommandSchema,
cwd: z.string().optional(),
env: EnvironmentSchema,
@@ -476,6 +476,7 @@ test("v1 extension maps stay explicit and future versions fail by protocol versi
assert.deepEqual(
CommandRuntimeLifecycleRequestSchema.parse({
protocolVersion: 1,
runtimeInstanceId: examples.lifecycleRequest.runtimeInstanceId,
options: { root: "runtime-private-option", vendorExtension: { nested: true } },
}).options,
{ root: "runtime-private-option", vendorExtension: { nested: true } },
+7 -8
View File
@@ -71,7 +71,7 @@ Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain wher
## Workspace runtimes
New Workspace offers the built-in Local, Worktree, and Docker runtimes. The current Docker POC requires a locally built workspace-runtime image. Its configured default, `ghcr.io/getpaseo/workspace-runtime:latest`, is not published and does not currently work.
New Workspace always offers Local and Worktree. Paseo Desktop and CLI distributions also register Docker through the same command-runtime configuration available to users.
From the Paseo repository root, build the image:
@@ -79,23 +79,22 @@ From the Paseo repository root, build the image:
docker build -f packages/docker-workspace-runtime/Dockerfile -t paseo-workspace-runtime:local .
```
Then override `workspaceRuntimes.docker.image` in daemon configuration:
Then replace the distribution registration's opaque image option in daemon configuration:
```json
{
"workspaceRuntimes": {
"docker": {
"type": "docker",
"image": "paseo-workspace-runtime:local",
"enabled": true
"type": "command",
"label": "Docker",
"command": ["@getpaseo/docker-workspace-runtime"],
"options": { "image": "paseo-workspace-runtime:local", "bindMounts": [] }
}
}
}
```
Optional `providerEnvironment` values set the provider process environment. Optional bind mounts are available during Git materialization and in the running workspace. Their sources and targets must be absolute, targets must stay outside `/workspace`, and every mount must set `readOnly`. Set `enabled: false` to remove Docker from the runtime catalog.
Trusted external runtimes use `type: "command"`. `command` and optional `helperCommand` are argv arrays, not shell strings. Optional `label` names the runtime in New Workspace, `providerEnvironment` sets the isolated provider environment, and `options` passes runtime-owned configuration to the adapter. Runtime implementations follow the [`@getpaseo/workspace-runtime-contract`](https://github.com/getpaseo/paseo/tree/main/packages/workspace-runtime-contract).
Every registered runtime uses `type: "command"`. `command` is an argv array, not a shell string. Its first value may be a scoped package executable, filesystem path, or executable on `PATH`. Optional `label` names the runtime in New Workspace, and `options` passes arbitrary JSON to the runtime unchanged. Set a distribution runtime ID to `null` to remove it. Every runtime must provide `paseo-workspace-helper` inside its execution environment. Runtime implementations follow the [`@getpaseo/workspace-runtime-contract`](https://github.com/getpaseo/paseo/tree/main/packages/workspace-runtime-contract).
## Voice