diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f72882e36b..51c1a52305 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 diff --git a/docs/docker.md b/docs/docker.md index 920d5ed7d8..ff0501e483 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 3018a501f8..80827dc0c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c1a2040b1b..c1bcfc8cb3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index 8ddde1bf03..e858471750 100644 --- a/packages/app/e2e/support/helpers/e2e-worker.ts +++ b/packages/app/e2e/support/helpers/e2e-worker.ts @@ -196,8 +196,10 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise ...(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 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 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, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 8100b06af0..e1e1472818 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "محلي", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 5d91a0f264..d95b8975b5 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 14f930f276..13abeb0e27 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 946420024a..d8f119a447 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 007eac2c30..dba15fa621 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "ローカル", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index e901a7ead5..e57b0f2547 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -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: "로컬", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index be60baa0bb..70e61dabd8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 56b3b0d845..0846281578 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Локально", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 58640f3780..571e7dae89 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: "本地", diff --git a/packages/app/src/new-workspace-runtime/model.test.ts b/packages/app/src/new-workspace-runtime/model.test.ts index edacffa322..a4c93cd709 100644 --- a/packages/app/src/new-workspace-runtime/model.test.ts +++ b/packages/app/src/new-workspace-runtime/model.test.ts @@ -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", () => { diff --git a/packages/app/src/new-workspace-runtime/model.ts b/packages/app/src/new-workspace-runtime/model.ts index 4eea1abdfb..02fc89b917 100644 --- a/packages/app/src/new-workspace-runtime/model.ts +++ b/packages/app/src/new-workspace-runtime/model.ts @@ -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; } diff --git a/packages/cli/package.json b/packages/cli/package.json index 04be4e1b6c..a634149f9a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index a7afb4e6b7..b22fa24184 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -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; } diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 29d8df68c7..51aaee5df3 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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", diff --git a/packages/desktop/scripts/dev-build-contract.test.mjs b/packages/desktop/scripts/dev-build-contract.test.mjs new file mode 100644 index 0000000000..5ee938162f --- /dev/null +++ b/packages/desktop/scripts/dev-build-contract.test.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 }); + } +} diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index faf4a7a101..f9453a50ee 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -398,6 +398,15 @@ async function startDaemon(): Promise { 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"], }); diff --git a/packages/docker-workspace-runtime/package.json b/packages/docker-workspace-runtime/package.json index 6a49386b9c..1bbc227b70 100644 --- a/packages/docker-workspace-runtime/package.json +++ b/packages/docker-workspace-runtime/package.json @@ -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" }, diff --git a/packages/docker-workspace-runtime/src/index.ts b/packages/docker-workspace-runtime/src/index.ts index f668ee06bf..78614424ea 100644 --- a/packages/docker-workspace-runtime/src/index.ts +++ b/packages/docker-workspace-runtime/src/index.ts @@ -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 { 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 { diff --git a/packages/fixture-workspace-runtime/src/index.mjs b/packages/fixture-workspace-runtime/src/index.mjs index 8c7be218d9..fde3fd128a 100755 --- a/packages/fixture-workspace-runtime/src/index.mjs +++ b/packages/fixture-workspace-runtime/src/index.mjs @@ -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), { diff --git a/packages/server/package.json b/packages/server/package.json index 89b0537c67..4f347d2a5c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts index 684bd73cf5..966413ba7c 100644 --- a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts +++ b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts @@ -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 }, }, } diff --git a/packages/server/src/server/agent/providers/workspace/index.ts b/packages/server/src/server/agent/providers/workspace/index.ts index 9431446c21..694937f47f 100644 --- a/packages/server/src/server/agent/providers/workspace/index.ts +++ b/packages/server/src/server/agent/providers/workspace/index.ts @@ -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; } 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; }; const run = (launch: ProviderWorkspaceLaunchInput): Promise => diff --git a/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts index a28f7758c6..8366d0a136 100644 --- a/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts +++ b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts @@ -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 = []; const bound = await service.bind("provider-state"); const recordingRuntime: BoundWorkspaceRuntime = { + provider: { environment: "isolated", sharedHostProviders: new Set() }, ...bound, resolveCommand(command) { return command === "node" diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3f3e02c5bd..2e03f763f1 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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>; + workspaceRuntimeCommandResolutionBase?: string; corsAllowedOrigins: string[]; allowedHosts?: HostnamesConfig; hostnames?: HostnamesConfig; @@ -868,7 +868,7 @@ export async function createPaseoDaemon( logger, ); const providerWorkspaceBindings = new Map>(); - 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> | undefined, -): Readonly> { - 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 { const agents = agentManager.listAgents(); await Promise.all( diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index f5d4eea94a..1824363b7f 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -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" }), diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index d35722b9a0..894e3e048f 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -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]), + ), + ); +} diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 3b7e177f18..316fe76b01 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -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 } }, }); }); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index d10970353f..603c9bd554 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -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 }); }); }); diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 6f691634fd..9da53a63c6 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -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}$/, { diff --git a/packages/server/src/server/workspace-runtime/command/index.ts b/packages/server/src/server/workspace-runtime/command/index.ts index 582f2989b2..13648d192b 100644 --- a/packages/server/src/server/workspace-runtime/command/index.ts +++ b/packages/server/src/server/workspace-runtime/command/index.ts @@ -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>; - helperCommand?: readonly [string, ...string[]]; + options?: Readonly>; } 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, + ); } diff --git a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts index 10a02e72c6..a836c16ef6 100644 --- a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts +++ b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts @@ -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>; - helperCommand?: readonly [string, ...string[]]; + options?: Readonly>; } export function createCommandRuntime( runtimeId: string, config: CommandRuntimeConfig, + runtimeInstanceId: string, + packageResolutionBase: string, + pathResolutionBase: string, ): WorkspaceRuntimeDriver { + const command = resolveRuntimeCommand(config.command, packageResolutionBase, pathResolutionBase); let described: Promise> | 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 { - 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>, + 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>, + runtimeInstanceId: string, wrapper: { pid?: number; kill(signal?: NodeJS.Signals): boolean }, wrapperClosed: Promise, ): Promise { @@ -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>, + 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; + }; + 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)]; +} diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index 41c3d1bac0..855f808ada 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -105,6 +105,7 @@ export interface WorkspaceRuntimeDriver { env: Readonly>; }; readonly scriptTerminal: import("../index.js").WorkspaceScriptTerminal; + readonly provider: import("../index.js").WorkspaceRuntimeProviderCapability; create(input: WorkspaceDriverCreateInput): Promise; inspect(workspaceId: WorkspaceId): Promise; spawn(input: WorkspaceDriverSpawnInput): Promise; diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 930c128a94..31d4770e37 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -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): Promise; resolveCommand(command: string): Promise; readonly scriptTerminal: WorkspaceScriptTerminal; + readonly provider: WorkspaceRuntimeProviderCapability; readonly files: WorkspaceFiles; } +export interface WorkspaceRuntimeProviderCapability { + readonly environment: "inherit-sanitized-host" | "isolated"; + readonly sharedHostProviders: ReadonlySet; +} + export interface WorkspaceRuntimeService { listRuntimes(): readonly WorkspaceRuntimeCatalogEntry[]; reconcile(): Promise; @@ -185,47 +191,22 @@ export interface ExternalWorkspaceRuntime { type: "command"; label?: string; command: readonly [string, ...string[]]; - options?: Readonly>; - helperCommand?: readonly [string, ...string[]]; - /** Explicit provider base environment for an isolated runtime. Host env is never inherited. */ - providerEnvironment?: Readonly>; + options?: Readonly>; } - -export interface DockerWorkspaceRuntime { - type: "docker"; - image?: string; - bindMounts?: readonly DockerBindMount[]; - providerEnvironment?: Readonly>; - 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>; -} - -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([ ["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, ), diff --git a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts index 037f102f7c..d1838ad7b4 100644 --- a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts @@ -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") { diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index f83179b670..9f61496e90 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -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); diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts index bc3658ad62..4ca50215da 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -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") { diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts new file mode 100644 index 0000000000..9441954758 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts @@ -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; + scripts?: Record; +} { + return JSON.parse(readFileSync(path.join(repositoryRoot, relativePath), "utf8")); +} diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts index 1abfd9df58..f3174bb3a3 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts @@ -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(); + 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(); + 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> = {}, - 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 } : {}), }, diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts index 4b703ae31f..f76b672b72 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts @@ -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> = {}) { + return { + type: "command" as const, + label: "Docker", + command: ["@getpaseo/docker-workspace-runtime"] as const, + options: { image, ...options }, + }; +} async function collect(stream: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; @@ -57,6 +58,8 @@ async function collect(stream: NodeJS.ReadableStream): Promise { } 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("\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[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(); const volumes = new Set(); const workspaceIds = new Set(); @@ -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(); return createWorkspaceRuntimeService({ paseoHome: input.paseoHome, @@ -1771,7 +1780,7 @@ function createCommandDockerService(input: { process.execPath, runtimeExecutable, ], - options: { image, owner: input.owner }, + options: { image }, }, }, }); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index 6e9bcdf4a9..64df571d87 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -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 }, }, } diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 8a475dd2db..985539dc79 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -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" } diff --git a/packages/workspace-runtime-contract/README.md b/packages/workspace-runtime-contract/README.md index 23e893a600..07bbea4efa 100644 --- a/packages/workspace-runtime-contract/README.md +++ b/packages/workspace-runtime-contract/README.md @@ -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 diff --git a/packages/workspace-runtime-contract/examples/v1.json b/packages/workspace-runtime-contract/examples/v1.json index 5b53d037b5..c8e724cf83 100644 --- a/packages/workspace-runtime-contract/examples/v1.json +++ b/packages/workspace-runtime-contract/examples/v1.json @@ -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", diff --git a/packages/workspace-runtime-contract/src/index.ts b/packages/workspace-runtime-contract/src/index.ts index 1804341f81..8d011bc60d 100644 --- a/packages/workspace-runtime-contract/src/index.ts +++ b/packages/workspace-runtime-contract/src/index.ts @@ -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, diff --git a/packages/workspace-runtime-contract/test/contract.test.mjs b/packages/workspace-runtime-contract/test/contract.test.mjs index d2d6e1e871..39c0fc1d23 100644 --- a/packages/workspace-runtime-contract/test/contract.test.mjs +++ b/packages/workspace-runtime-contract/test/contract.test.mjs @@ -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 } }, diff --git a/public-docs/configuration.md b/public-docs/configuration.md index 01cdfa1b25..cd2c8844b0 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -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