refactor(workspaces): finalize runtime package boundaries

Keep the daemon dependent only on the public runtime contract and helper, while optional runtime implementations remain external command registrations. Workspace setup continues asynchronously through the bound runtime without coupling production packages to an implementation.
This commit is contained in:
Mohamed Boudra
2026-08-14 12:48:35 +02:00
parent 36d8464068
commit 7c32fcda98
85 changed files with 2293 additions and 4664 deletions
+2 -1
View File
@@ -35,7 +35,8 @@ hub:
server:
- "packages/server/**"
- "packages/fixture-workspace-runtime/**"
- "runtimes/fixture/**"
- "packages/workspace-helper/**"
- "packages/workspace-runtime-contract/**"
- "packages/app/e2e/support/fixtures/recording.*"
+10 -1
View File
@@ -133,6 +133,7 @@ jobs:
run: |
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-runtime-contract
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/workspace-helper
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
@@ -169,7 +170,15 @@ jobs:
- name: Test workspace runtime command contract package
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
run: npm test --workspace=@getpaseo/workspace-runtime-contract
run: npm test --workspace=@getpaseo/workspace-runtime-contract && npm test --workspace=@getpaseo/workspace-helper
- name: Prove the command runtime fixture is independently installable
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
run: node --test scripts/workspace-runtime-standalone.test.mjs
- name: Prove clean server builds own public prerequisites
if: ${{ runner.os == 'Linux' && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false') }}
run: node --test scripts/server-clean-build.test.mjs
- name: Run server tests
if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }}
+1 -42
View File
@@ -16,9 +16,8 @@ on:
- "packages/app/**"
- "packages/cli/**"
- "packages/client/**"
- "packages/docker-workspace-runtime/**"
- "packages/desktop/**"
- "packages/fixture-workspace-runtime/**"
- "packages/workspace-helper/**"
- "packages/workspace-runtime-contract/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
@@ -159,46 +158,6 @@ jobs:
cache-from: type=gha,scope=paseo
cache-to: type=gha,scope=paseo,mode=max
workspace-runtime-test:
needs: setup
if: needs.setup.outputs.publish != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
- name: Build server 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
- name: Test Docker Desktop acceptance journey
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop -- e2e/new-workspace-docker.spec.ts --workers=1
env:
E2E_DOCKER_ACCEPTANCE: "1"
E2E_DOCKER_WORKSPACE_IMAGE: paseo-workspace-runtime-e2e:test
publish:
needs: setup
if: needs.setup.outputs.publish == 'true'
+2
View File
@@ -22,6 +22,8 @@ RUN set -eux; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
+13
View File
@@ -88,6 +88,19 @@ agent timeline types, provider config schemas, and other values shared by daemon
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
it does not depend on the server.
### Workspace runtime packages
`packages/workspace-runtime-contract` owns the versioned command-runtime lifecycle and exec wire
contract. `packages/workspace-helper` owns the official confined files/watch/command-resolution
executable and its typed binding. The daemon depends on those two packages and registers command
runtimes without importing an implementation.
`runtimes/fixture` is a private generic contract fixture. Runtime implementations live in their own
repositories and depend on published versions of the two public workspace packages. CLI, Desktop,
server, release workflows, and the daemon image do not depend on, bundle, publish, or register an
implementation. Tests invoke registered runtimes through their public command, never through source
imports.
### `packages/client` — Daemon client library and SDK facade
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
+1 -29
View File
@@ -10,7 +10,7 @@ The image source lives in [`docker/`](../docker/).
The official image:
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
- builds `@getpaseo/server`, `@getpaseo/cli`, and their public workspace packages from source-built workspace tarballs
- runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
@@ -22,34 +22,6 @@ the web UI. The served app receives a same-origin connection hint and connects
back to that daemon. Static UI files load without daemon auth; API and
WebSocket requests still require `PASEO_PASSWORD` when one is configured.
## Docker workspace runtime
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 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 .
```
```json
{
"workspaceRuntimes": {
"docker": {
"type": "command",
"label": "Docker",
"command": ["@getpaseo/docker-workspace-runtime"],
"options": {
"image": "paseo-workspace-runtime:local",
"bindMounts": []
}
}
}
}
```
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
```bash
+35 -34
View File
@@ -21,8 +21,8 @@
"packages/desktop",
"packages/cli",
"packages/workspace-runtime-contract",
"packages/fixture-workspace-runtime",
"packages/docker-workspace-runtime"
"packages/workspace-helper",
"runtimes/fixture"
],
"devDependencies": {
"@types/ws": "^8.5.14",
@@ -6237,16 +6237,12 @@
"resolved": "packages/desktop",
"link": true
},
"node_modules/@getpaseo/docker-workspace-runtime": {
"resolved": "packages/docker-workspace-runtime",
"link": true
},
"node_modules/@getpaseo/expo-two-way-audio": {
"resolved": "packages/expo-two-way-audio",
"link": true
},
"node_modules/@getpaseo/fixture-workspace-runtime": {
"resolved": "packages/fixture-workspace-runtime",
"resolved": "runtimes/fixture",
"link": true
},
"node_modules/@getpaseo/highlight": {
@@ -6269,6 +6265,10 @@
"resolved": "packages/website",
"link": true
},
"node_modules/@getpaseo/workspace-helper": {
"resolved": "packages/workspace-helper",
"link": true
},
"node_modules/@getpaseo/workspace-runtime-contract": {
"resolved": "packages/workspace-runtime-contract",
"link": true
@@ -36166,7 +36166,6 @@
"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",
@@ -36433,7 +36432,6 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
"@getpaseo/docker-workspace-runtime": "*",
"@getpaseo/server": "*",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
@@ -36671,20 +36669,6 @@
}
}
},
"packages/docker-workspace-runtime": {
"name": "@getpaseo/docker-workspace-runtime",
"version": "0.3.1",
"dependencies": {
"@getpaseo/workspace-runtime-contract": "*"
},
"bin": {
"paseo-docker-workspace-runtime": "dist/index.js"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.9.3"
}
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.3.1",
@@ -37581,17 +37565,6 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
"packages/fixture-workspace-runtime": {
"name": "@getpaseo/fixture-workspace-runtime",
"version": "0.3.1",
"dependencies": {
"@getpaseo/workspace-runtime-contract": "*",
"node-pty": "1.2.0-beta.15"
},
"bin": {
"paseo-fixture-workspace-runtime": "src/index.mjs"
}
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.3.1",
@@ -38066,6 +38039,7 @@
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@getpaseo/workspace-helper": "0.3.1",
"@getpaseo/workspace-runtime-contract": "0.3.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -40316,6 +40290,21 @@
}
}
},
"packages/workspace-helper": {
"name": "@getpaseo/workspace-helper",
"version": "0.3.1",
"dependencies": {
"zod": "^4.4.3"
},
"bin": {
"paseo-workspace-helper": "dist/executable.mjs"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.9.3",
"vitest": "^4.1.6"
}
},
"packages/workspace-runtime-contract": {
"name": "@getpaseo/workspace-runtime-contract",
"version": "0.3.1",
@@ -40325,6 +40314,18 @@
"devDependencies": {
"typescript": "^5.9.3"
}
},
"runtimes/fixture": {
"name": "@getpaseo/fixture-workspace-runtime",
"version": "0.3.1",
"dependencies": {
"@getpaseo/workspace-helper": "0.3.1",
"@getpaseo/workspace-runtime-contract": "0.3.1",
"node-pty": "1.2.0-beta.15"
},
"bin": {
"paseo-fixture-workspace-runtime": "src/index.mjs"
}
}
}
}
+11 -12
View File
@@ -32,8 +32,8 @@
"packages/desktop",
"packages/cli",
"packages/workspace-runtime-contract",
"packages/fixture-workspace-runtime",
"packages/docker-workspace-runtime"
"packages/workspace-helper",
"runtimes/fixture"
],
"scripts": {
"dev": "npm run dev:server",
@@ -56,11 +56,10 @@
"build:client": "npm run build --workspace=@getpaseo/client",
"build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client",
"build:workspace-runtime-contract": "npm run build --workspace=@getpaseo/workspace-runtime-contract",
"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 --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:workspace-helper": "npm run build --workspace=@getpaseo/workspace-helper",
"build:workspace-runtime-fixture": "npm run build --workspace=@getpaseo/fixture-workspace-runtime",
"build:server-deps": "npm run build:workspace-runtime-contract && npm run build:workspace-helper && 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:clean --workspace=@getpaseo/workspace-runtime-contract && npm run build:clean --workspace=@getpaseo/workspace-helper && 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",
@@ -107,11 +106,11 @@
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/workspace-runtime-contract && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/workspace-runtime-contract && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/workspace-runtime-contract && npm run typecheck --workspace=@getpaseo/workspace-helper && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/workspace-runtime-contract && npm pack --dry-run --workspace=@getpaseo/workspace-helper && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public && npm publish --workspace=@getpaseo/workspace-helper --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/workspace-runtime-contract --access public --tag beta && npm publish --workspace=@getpaseo/workspace-helper --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
"release:push": "node scripts/push-current-release-tag.mjs",
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
@@ -287,7 +287,7 @@ test.describe("New workspace entry points", () => {
await expectNewWorkspaceProjectSelected(page, gitProject.projectDisplayName);
await expectWorkspaceRuntimeSelected(page, "local");
await expectWorkspaceRuntimeChoices(page, ["local", "worktree", "docker"]);
await expectWorkspaceRuntimeChoices(page, ["local", "worktree"]);
} finally {
await gitProject.cleanup();
await nonGitProject.cleanup();
+5 -32
View File
@@ -148,13 +148,8 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void>
const materializeRoot = path.join(paseoHome, "fixture-workspaces");
const fixtureProviderSource = path.resolve(
__dirname,
"../../../../server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs",
"../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs",
);
const dockerAcceptance = process.env.E2E_DOCKER_ACCEPTANCE === "1";
const dockerImage = process.env.E2E_DOCKER_WORKSPACE_IMAGE;
if (dockerAcceptance && !dockerImage) {
throw new Error("E2E_DOCKER_WORKSPACE_IMAGE is required for Docker acceptance");
}
await mkdir(stateDirectory, { recursive: true });
await writeFile(
configPath,
@@ -168,47 +163,25 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void>
claude: { ...existing.agents?.providers?.claude, enabled: false },
codex: { ...existing.agents?.providers?.codex, enabled: false },
copilot: { ...existing.agents?.providers?.copilot, enabled: false },
opencode: {
...existing.agents?.providers?.opencode,
enabled: dockerAcceptance,
},
opencode: { ...existing.agents?.providers?.opencode, enabled: false },
pi: { ...existing.agents?.providers?.pi, enabled: false },
"fixture-agent": {
extends: "acp",
label: "Fixture Agent",
command: [process.execPath, "./.paseo-fixture-agent.mjs"],
models: [{ id: "fixture-model", label: "Fixture Model", isDefault: true }],
enabled: !dockerAcceptance,
enabled: true,
},
...(dockerAcceptance
? {
"docker-fixture": {
extends: "acp",
label: "Docker Fixture",
command: ["node", "/usr/local/bin/paseo-fixture-agent.mjs"],
},
}
: {}),
},
},
workspaceRuntimes: {
...existing.workspaceRuntimes,
...(dockerAcceptance
? {
docker: {
type: "command",
label: "Docker",
command: ["@getpaseo/docker-workspace-runtime"],
options: { image: dockerImage, bindMounts: [] },
},
}
: {}),
fixture: {
type: "command",
label: "Fixture",
command: [
process.execPath,
path.resolve(__dirname, "../../../../fixture-workspace-runtime/src/index.mjs"),
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
],
options: {
stateDirectory,
@@ -222,7 +195,7 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void>
label: "Fixture Failure",
command: [
process.execPath,
path.resolve(__dirname, "../../../../fixture-workspace-runtime/src/index.mjs"),
path.resolve(__dirname, "../../../../../runtimes/fixture/src/index.mjs"),
],
options: {
stateDirectory: path.join(paseoHome, "fixture-failure-runtime"),
@@ -1,8 +1,5 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { access, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { access, readFile, readdir } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { expect, type Page } from "@playwright/test";
import { gotoAppShell } from "./app";
import { getE2EDaemonPort } from "./daemon-port";
@@ -16,9 +13,7 @@ import {
} from "./new-workspace";
import { connectSeedClient } from "./seed-client";
import { getServerId } from "./server-id";
import { getTerminalBufferText } from "./terminal-perf";
import { createTempDirectory, createTempGitRepo } from "./workspace";
import { clickFirstTerminalTab } from "./workspace-tabs";
export interface SeededRuntimeProject {
projectId: string;
@@ -28,27 +23,6 @@ export interface SeededRuntimeProject {
cleanup(): Promise<void>;
}
interface HostDecoySnapshot {
content: string;
mtimeMs: number;
}
export interface SeededDockerRuntimeProject extends SeededRuntimeProject {
hostMutationPath: string;
expectHostDecoysUntouched(): Promise<void>;
mutateHostAfterDockerCreation(): Promise<void>;
}
const run = promisify(execFile);
const DOCKER_CREATED_FILE = "made-in-container.txt";
const DOCKER_SCRIPT_FILE = "workspace-script-output.txt";
const DOCKER_AGENT_FILE = "stdio-agent-output.txt";
const DOCKER_RUNTIME_LABELS = {
runtime: "sh.paseo.runtime",
runtimeOwner: "sh.paseo.runtime-owner",
workspaceId: "sh.paseo.workspace-id",
} as const;
const SETUP_MARKER = "runtime-user-setup-ran.txt";
export async function seedGitProjectForRuntime(
@@ -63,61 +37,6 @@ export async function seedGitProjectForRuntime(
return seedRuntimeProject(repo);
}
export async function seedGitProjectWithDockerDecoys(): Promise<SeededDockerRuntimeProject> {
const repo = await createTempGitRepo("runtime-docker-", {
paseoConfig: {
worktree: {
setup: ['node -e "setTimeout(() => {}, 65000)"'],
},
scripts: {
"docker-proof": {
command: "pwd > workspace-script-output.txt; sleep 10",
},
},
},
files: [
{ path: "committed.txt", content: "committed host baseline\n" },
{ path: "provider-model.txt", content: "docker-fixture-model\n" },
{ path: "host-mutation.txt", content: "committed before Docker creation\n" },
],
});
const decoyContents = new Map([
[DOCKER_CREATED_FILE, "host decoy: terminal must not overwrite this\n"],
[DOCKER_SCRIPT_FILE, "host decoy: script must not overwrite this\n"],
[DOCKER_AGENT_FILE, "host decoy: agent must not overwrite this\n"],
]);
for (const [name, content] of decoyContents) {
await writeFile(path.join(repo.path, name), content);
}
const snapshots = new Map<string, HostDecoySnapshot>();
for (const name of decoyContents.keys()) {
const filePath = path.join(repo.path, name);
const metadata = await stat(filePath);
snapshots.set(name, { content: await readFile(filePath, "utf8"), mtimeMs: metadata.mtimeMs });
}
const project = await seedRuntimeProject(repo);
return {
...project,
hostMutationPath: path.join(repo.path, "host-mutation.txt"),
mutateHostAfterDockerCreation: async () => {
await writeFile(
path.join(repo.path, "host-mutation.txt"),
"host changed after Docker creation\n",
);
},
expectHostDecoysUntouched: async () => {
for (const [name, snapshot] of snapshots) {
const filePath = path.join(repo.path, name);
const metadata = await stat(filePath);
expect(await readFile(filePath, "utf8"), `${name} host decoy content`).toBe(
snapshot.content,
);
expect(metadata.mtimeMs, `${name} host decoy mtime`).toBe(snapshot.mtimeMs);
}
},
};
}
async function readProbeRecords(projectId: string): Promise<Array<{ workspaceId: string }>> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
@@ -409,299 +328,3 @@ export async function expectHostWorkspaceAffordances(page: Page): Promise<void>
await expect(page.getByText("Finder", { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
}
export async function expectDockerHasNoHostAffordance(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: /Open workspace in/ })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Choose editor" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Open in file manager/ })).toHaveCount(0);
}
export async function expectDockerWorkspaceHasNoHostVisiblePath(
project: SeededDockerRuntimeProject,
): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const records = JSON.parse(
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
) as Array<{
projectId: string;
runtime?: { runtimeId: string };
hostVisiblePath?: string;
}>;
const workspace = records.find(
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === "docker",
);
expect(workspace, "Docker user workspace record").toBeDefined();
expect(workspace?.hostVisiblePath).toBeNull();
}
export async function expectDockerTerminalText(page: Page, text: string): Promise<void> {
await expect.poll(() => getTerminalBufferText(page), { timeout: 30_000 }).toContain(text);
}
export async function expectDockerScriptTerminalOpen(
page: Page,
scriptName: string,
): Promise<void> {
const scriptTab = page
.locator('[data-testid^="workspace-tab-terminal_"]')
.filter({ hasText: scriptName });
await expect(scriptTab).toBeVisible({ timeout: 30_000 });
await scriptTab.click();
}
export async function openDockerUncommittedChanges(page: Page): Promise<void> {
await page.getByTestId("explorer-tab-changes").click();
await page.getByRole("button", { name: "Diff mode", exact: true }).click();
await page.getByTestId("changes-diff-mode-uncommitted").click();
await expect(page.getByRole("button", { name: "Diff mode", exact: true })).toHaveText(
"Uncommitted",
);
}
export async function commitDockerWorkspaceChanges(page: Page): Promise<void> {
await clickFirstTerminalTab(page);
const terminal = page.locator('[data-testid="terminal-surface"]').first();
await terminal.click();
await terminal.pressSequentially(
"git add committed.txt made-in-container.txt && git -c user.name='Paseo E2E' -c user.email='paseo@example.test' commit -m 'Docker UI acceptance commit'\n",
{ delay: 0 },
);
await expectDockerTerminalText(page, "Docker UI acceptance commit");
}
export async function cleanupDockerRuntimeProject(
project: SeededDockerRuntimeProject,
): Promise<void> {
const paseoHome = process.env.E2E_PASEO_HOME;
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
const recordPaths = [
path.join(paseoHome, "projects", "provider-probes.json"),
path.join(paseoHome, "projects", "workspaces.json"),
];
const workspaceIds: string[] = [];
const userWorkspaceIds: string[] = [];
for (const recordPath of recordPaths) {
try {
await access(recordPath);
} catch {
continue;
}
const records = JSON.parse(await readFile(recordPath, "utf8")) as Array<{
projectId: string;
workspaceId: string;
runtimeId?: string;
runtime?: { runtimeId: string };
}>;
const matchingWorkspaceIds = records
.filter(
(record) =>
record.projectId === project.projectId &&
(record.runtimeId === "docker" || record.runtime?.runtimeId === "docker"),
)
.map((record) => record.workspaceId);
workspaceIds.push(...matchingWorkspaceIds);
if (recordPath.endsWith("workspaces.json")) userWorkspaceIds.push(...matchingWorkspaceIds);
}
const names = workspaceIds.map((workspaceId) => dockerResourceName(workspaceId));
const userNames = userWorkspaceIds.map((workspaceId) => dockerResourceName(workspaceId));
const runtimeOwner = createHash("sha256").update(path.resolve(paseoHome)).digest("hex");
let productionCleanupComplete = false;
try {
const cleanupClient = await connectSeedClient();
try {
const agents = await cleanupClient.fetchAgents({ scope: "active" });
const ownedAgents = agents.entries.filter((entry) =>
userWorkspaceIds.includes(entry.agent.workspaceId ?? ""),
);
await Promise.all(ownedAgents.map((entry) => cleanupClient.cancelAgent(entry.agent.id)));
await Promise.all(ownedAgents.map((entry) => cleanupClient.archiveAgent(entry.agent.id)));
for (const workspaceId of userWorkspaceIds) {
const scripts = await cleanupClient.listWorkspaceScripts(workspaceId);
await Promise.all(
scripts.scripts
.filter((script) => script.lifecycle === "running")
.map((script) => cleanupClient.stopWorkspaceScript(workspaceId, script.scriptName)),
);
const listed = await cleanupClient.listTerminals(undefined, undefined, { workspaceId });
await Promise.all(
listed.terminals.map((terminal) => cleanupClient.killTerminal(terminal.id)),
);
await expect
.poll(
async () =>
(await cleanupClient.listTerminals(undefined, undefined, { workspaceId })).terminals
.length,
{ timeout: 15_000 },
)
.toBe(0);
}
} finally {
await cleanupClient.close();
}
await cleanupProjectThroughProductionLifecycle(project, userWorkspaceIds, userNames);
productionCleanupComplete = true;
await expectDockerRuntimeGone(userWorkspaceIds, userNames);
} finally {
await cleanupValidatedDockerResources(workspaceIds, runtimeOwner);
await expectDockerRuntimeGone(workspaceIds, names);
expect(productionCleanupComplete).toBe(true);
}
}
async function cleanupProjectThroughProductionLifecycle(
project: SeededDockerRuntimeProject,
workspaceIds: readonly string[],
names: readonly string[],
): Promise<void> {
try {
await project.cleanup();
return;
} catch (firstError) {
const lingeringProcesses = (await dockerRuntimeLeaks(workspaceIds, names)).processes;
if (lingeringProcesses.length > 0) {
await run("kill", ["-KILL", ...lingeringProcesses.map(String)]);
await expect
.poll(async () => (await dockerRuntimeLeaks(workspaceIds, names)).processes.length, {
timeout: 15_000,
})
.toBe(0);
}
try {
await project.cleanup();
} catch (retryError) {
throw new Error(
`Docker project cleanup failed through the production lifecycle after ${String(firstError)}`,
{ cause: retryError },
);
}
}
}
async function expectDockerRuntimeGone(
workspaceIds: readonly string[],
names: readonly string[],
): Promise<void> {
await expect
.poll(() => dockerRuntimeLeaks(workspaceIds, names), { timeout: 15_000 })
.toEqual({ containers: [], processes: [], volumes: [] });
}
async function cleanupValidatedDockerResources(
workspaceIds: readonly string[],
runtimeOwner: string,
): Promise<void> {
for (const workspaceId of workspaceIds) {
const name = dockerResourceName(workspaceId);
const [container, volume] = await Promise.all([
inspectDockerTestResource("container", name),
inspectDockerTestResource("volume", name),
]);
assertDockerTestResourceOwner(container, { name, runtimeOwner, workspaceId });
assertDockerTestResourceOwner(volume, { name, runtimeOwner, workspaceId });
if (container) await removeDockerTestResource("container", name);
if (volume) await removeDockerTestResource("volume", name);
}
}
interface DockerTestResourceInspection {
labels: Record<string, string>;
name: string;
}
async function inspectDockerTestResource(
kind: "container" | "volume",
name: string,
): Promise<DockerTestResourceInspection | null> {
try {
const { stdout } = await run("docker", [kind, "inspect", name]);
const [inspection] = JSON.parse(stdout) as Array<{
Config?: { Labels?: Record<string, string> | null };
Labels?: Record<string, string> | null;
Name?: string;
}>;
return {
labels: (kind === "container" ? inspection?.Config?.Labels : inspection?.Labels) ?? {},
name: inspection?.Name ?? name,
};
} catch (error) {
if (await dockerTestResourceMissing(kind, name)) return null;
throw error;
}
}
function assertDockerTestResourceOwner(
resource: DockerTestResourceInspection | null,
expected: { name: string; runtimeOwner: string; workspaceId: string },
): void {
if (!resource) return;
expect(resource.labels).toMatchObject({
[DOCKER_RUNTIME_LABELS.runtime]: "workspace",
[DOCKER_RUNTIME_LABELS.runtimeOwner]: expected.runtimeOwner,
[DOCKER_RUNTIME_LABELS.workspaceId]: expected.workspaceId,
});
}
async function removeDockerTestResource(kind: "container" | "volume", name: string): Promise<void> {
try {
await run("docker", kind === "container" ? ["rm", "-f", name] : ["volume", "rm", "-f", name]);
} catch (error) {
if (await dockerTestResourceMissing(kind, name)) return;
throw error;
}
}
async function dockerTestResourceMissing(
kind: "container" | "volume",
name: string,
): Promise<boolean> {
try {
await run("docker", [kind, "inspect", name]);
return false;
} catch {
return true;
}
}
function dockerResourceName(workspaceId: string): string {
const key = createHash("sha256").update(workspaceId).digest("hex").slice(0, 20);
return `paseo-ws-${key}`;
}
async function dockerRuntimeLeaks(
workspaceIds: readonly string[],
names: readonly string[],
): Promise<{ containers: string[]; processes: number[]; volumes: string[] }> {
const containers = await existingDockerResources("container", names);
const volumes = await existingDockerResources("volume", names);
const { stdout } = await run("ps", ["-axo", "pid=,command="]);
const needles = workspaceIds.flatMap((workspaceId) => [
dockerResourceName(workspaceId),
`--workspace-id ${workspaceId}`,
]);
const processes = stdout
.split("\n")
.map((line) => line.match(/^\s*(\d+)\s+(.*)$/u))
.filter((match): match is RegExpMatchArray =>
Boolean(match && needles.some((needle) => match[2]?.includes(needle))),
)
.map((match) => Number(match[1]));
return { containers, processes, volumes };
}
async function existingDockerResources(
kind: "container" | "volume",
names: readonly string[],
): Promise<string[]> {
const checks = await Promise.all(
names.map(async (name) => {
try {
await run("docker", [kind, "inspect", name]);
return name;
} catch {
return null;
}
}),
);
return checks.filter((name): name is string => name !== null);
}
@@ -317,10 +317,9 @@ export async function selectNewWorkspaceProject(
await expectNewWorkspaceProjectSelected(page, input.projectDisplayName);
}
type WorkspaceRuntimeChoice = "docker" | "local" | "worktree";
type WorkspaceRuntimeChoice = "local" | "worktree";
const WORKSPACE_RUNTIME_LABEL: Record<WorkspaceRuntimeChoice, string> = {
docker: "Docker",
local: "Local",
worktree: "Worktree",
};
-1
View File
@@ -29,7 +29,6 @@
"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",
@@ -99,20 +99,6 @@ 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;
@@ -168,8 +154,6 @@ 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;
}
-56
View File
@@ -2799,62 +2799,6 @@ test("lists workspace runtimes and sends an explicit creation runtime", async ()
await expect(createPromise).resolves.toMatchObject({ error: "fixture sentinel" });
});
test("keeps a selected-runtime workspace create request alive while runtime setup completes", async () => {
useHeartbeatClock();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_runtime_setup_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const createPromise = client.createWorkspace(
{
runtimeId: "docker",
source: {
kind: "directory",
path: "/tmp/project",
projectId: "local:/tmp/project",
},
},
"req-runtime-setup",
);
let settled = false;
void createPromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
await vi.advanceTimersByTimeAsync(65_000);
expect(settled).toBe(false);
mock.triggerMessage(
wrapSessionMessage({
type: "workspace.create.response",
payload: {
requestId: "req-runtime-setup",
workspace: null,
error: "late setup sentinel",
setupTerminalId: null,
},
}),
);
await expect(createPromise).resolves.toMatchObject({ error: "late setup sentinel" });
});
test("sends project.remove.request", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
-2
View File
@@ -966,7 +966,6 @@ function toTimeoutError(error: unknown, label: string, timeoutMs: number): Error
const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500;
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000;
const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000;
const WORKSPACE_CREATE_RPC_TIMEOUT_MS = 10 * 60_000;
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
const DEFAULT_LIVENESS_TIMEOUT_MS = 5000;
const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000;
@@ -4420,7 +4419,6 @@ export class DaemonClient {
: {}),
},
responseType: "workspace.create.response",
...(input.runtimeId !== undefined ? { timeout: WORKSPACE_CREATE_RPC_TIMEOUT_MS } : {}),
});
}
@@ -1,162 +0,0 @@
import { test as base } from "../../app/e2e/support/fixtures";
import { submitMessage } from "../../app/e2e/support/helpers/composer";
import {
expectExplorerEntryVisible,
openFileExplorer,
} from "../../app/e2e/support/helpers/file-explorer";
import { clickNewChat, clickNewTerminal } from "../../app/e2e/support/helpers/launcher";
import {
cleanupDockerRuntimeProject,
commitDockerWorkspaceChanges,
createWorkspaceInSelectedRuntime,
expectDockerHasNoHostAffordance,
expectDockerScriptTerminalOpen,
expectDockerTerminalText,
expectDockerWorkspaceHasNoHostVisiblePath,
expectRuntimeProviderUnavailable,
expectWorkspaceOpenInRuntime,
gotoNewWorkspaceForRuntime,
openDockerUncommittedChanges,
seedGitProjectWithDockerDecoys,
selectRuntime,
selectRuntimeProviderModel,
type SeededDockerRuntimeProject,
} from "../../app/e2e/support/helpers/new-workspace-runtime";
import {
focusTerminalSurface,
getTerminalBufferText,
typeInTerminal,
expectTerminalSurfaceVisible,
waitForTerminalAttached,
} from "../../app/e2e/support/helpers/terminal-perf";
import {
closeWorkspaceScriptsMenu,
openWorkspaceScriptsMenu,
startWorkspaceScriptFromMenu,
} from "../../app/e2e/support/helpers/workspace-setup";
import { getServerId } from "../../app/e2e/support/helpers/server-id";
import { installDesktopRuntime } from "./support/runtime";
import { expect } from "@playwright/test";
const test = base.extend<{ dockerProject: SeededDockerRuntimeProject }>({
dockerProject: async ({ browserName: _browserName }, provide) => {
const project = await seedGitProjectWithDockerDecoys();
try {
await provide(project);
} finally {
await cleanupDockerRuntimeProject(project);
}
},
});
const hostOpenTargets = [
{
id: "vscode",
label: "VS Code",
kind: "editor" as const,
icon: { kind: "symbol" as const, name: "terminal" as const },
},
{
id: "finder",
label: "Finder",
kind: "file-manager" as const,
icon: { kind: "symbol" as const, name: "folder" as const },
},
];
test.describe.configure({ timeout: 240_000 });
test("Docker workspace: probe, create, and work inside the container", async ({
page,
dockerProject,
}) => {
await test.step("choose Docker and observe its provider catalog", async () => {
await installDesktopRuntime(page, {
serverId: getServerId(),
manageBuiltInDaemon: false,
editorTargets: hostOpenTargets,
});
await gotoNewWorkspaceForRuntime(page, dockerProject);
await selectRuntime(page, "Docker");
await expectRuntimeProviderUnavailable(page, "OpenCode");
await selectRuntimeProviderModel(page, {
provider: "Docker Fixture",
model: "Fixture Model docker-fixture-model",
});
});
await test.step("create the user workspace without host authority", async () => {
await createWorkspaceInSelectedRuntime(page);
await expectWorkspaceOpenInRuntime(page, dockerProject, "docker");
await expectDockerHasNoHostAffordance(page);
await expectDockerWorkspaceHasNoHostVisiblePath(dockerProject);
await dockerProject.mutateHostAfterDockerCreation();
});
await test.step("create and edit files from a terminal rooted at /workspace", async () => {
await clickNewTerminal(page);
await expectTerminalSurfaceVisible(page);
await waitForTerminalAttached(page);
await focusTerminalSurface(page);
await typeInTerminal(
page,
"pwd; cat host-mutation.txt; printf 'created in Docker\\n' > made-in-container.txt; printf 'changed in Docker\\n' > committed.txt\n",
);
await expectDockerTerminalText(page, "/workspace");
await expectDockerTerminalText(page, "committed before Docker creation");
expect(await getTerminalBufferText(page)).not.toContain("host changed after Docker creation");
});
await test.step("reflect terminal files and Git diff live", async () => {
await openFileExplorer(page);
await expectExplorerEntryVisible(page, "made-in-container.txt");
await openDockerUncommittedChanges(page);
await expect(page.getByText("made-in-container.txt", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
await expect(page.getByText("committed.txt", { exact: true }).first()).toBeVisible();
await expect(page.getByText("host-mutation.txt", { exact: true })).toHaveCount(0);
await page.getByText("committed.txt", { exact: true }).first().click();
await expect(page.getByText("changed in Docker", { exact: true })).toBeVisible();
await commitDockerWorkspaceChanges(page);
await page.getByRole("button", { name: "Close explorer" }).first().click();
});
await test.step("run the project workspace script inside Docker", async () => {
await openWorkspaceScriptsMenu(page);
await startWorkspaceScriptFromMenu(page, "docker-proof");
await closeWorkspaceScriptsMenu(page);
await expectDockerScriptTerminalOpen(page, "docker-proof");
await openFileExplorer(page);
await openDockerUncommittedChanges(page);
await expect(
page.getByText("workspace-script-output.txt", { exact: true }).first(),
).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Close explorer" }).first().click();
});
await test.step("launch the selected fixture agent and show its edit in Git", async () => {
await clickNewChat(page);
await selectRuntimeProviderModel(page, {
provider: "Docker Fixture",
model: "Fixture Model docker-fixture-model",
});
await submitMessage(page, "agent-owned change");
await expect(
page.getByText("fixture completed: agent-owned change", { exact: true }),
).toBeVisible({
timeout: 30_000,
});
await page.getByRole("button", { name: "Open explorer" }).first().click();
await openDockerUncommittedChanges(page);
await expect(page.getByText("stdio-agent-output.txt", { exact: true }).first()).toBeVisible({
timeout: 30_000,
});
});
await test.step("leave every host decoy untouched", async () => {
await dockerProject.expectHostDecoysUntouched();
});
});
@@ -71,7 +71,7 @@ test("probes and creates a workspace in a selected runtime", async ({ page, runt
editorTargets: hostOpenTargets,
});
await gotoNewWorkspaceForRuntime(page, runtimeProject);
await expectRuntimeChoices(page, ["Local", "Worktree", "Docker", "Fixture", "Fixture Failure"]);
await expectRuntimeChoices(page, ["Local", "Worktree", "Fixture", "Fixture Failure"]);
await selectRuntime(page, "Fixture");
});
+2 -3
View File
@@ -15,10 +15,10 @@
},
"main": "dist/main.js",
"scripts": {
"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": "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",
"dev:prerequisites": "npm --prefix ../.. run build:server-deps && npm --prefix ../.. run build --workspace=@getpaseo/server",
"predev": "npm run dev:prerequisites",
"dev": "./scripts/dev.sh",
"predev:win": "npm run dev:prerequisites",
@@ -31,7 +31,6 @@
},
"dependencies": {
"@getpaseo/cli": "*",
"@getpaseo/docker-workspace-runtime": "*",
"@getpaseo/server": "*",
"electron-log": "^5.4.3",
"electron-updater": "^6.6.2",
-3
View File
@@ -2,12 +2,9 @@ import { defineConfig, devices } from "@playwright/test";
const baseURL =
process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`;
const dockerAcceptance = process.env.E2E_DOCKER_ACCEPTANCE === "1";
export default defineConfig({
testDir: "./e2e",
testMatch: ["**/*.spec.ts"],
testIgnore: dockerAcceptance ? [] : ["**/new-workspace-docker.spec.ts"],
globalSetup: "../app/e2e/support/global-setup.ts",
timeout: 60_000,
expect: {
@@ -29,7 +29,7 @@ describe("desktop dev build contract", () => {
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",
"npm --prefix ../.. run build:server-deps && 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");
@@ -72,12 +72,12 @@ async function createIsolatedBuildCheckout() {
const packageDirectories = [
"packages/client",
"packages/desktop",
"packages/docker-workspace-runtime",
"packages/highlight",
"packages/protocol",
"packages/relay",
"packages/server",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
];
try {
@@ -398,15 +398,6 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
PASEO_DESKTOP_MANAGED: "1",
PASEO_CLI: getBundledCliShimPath(),
PASEO_WEB_UI_ENABLED: "false",
PASEO_DISTRIBUTION_WORKSPACE_RUNTIMES: JSON.stringify({
docker: {
type: "command",
label: "Docker",
command: ["@getpaseo/docker-workspace-runtime"],
options: { image: "paseo-workspace-runtime:phase3-qa", bindMounts: [] },
},
}),
PASEO_DISTRIBUTION_PACKAGE_ROOT: __dirname,
},
stdio: ["ignore", "ignore", "ignore"],
});
@@ -108,13 +108,11 @@ describe("desktop packaging", () => {
};
const deps = pkg.dependencies ?? {};
for (const required of [
"@getpaseo/cli",
"@getpaseo/docker-workspace-runtime",
"@getpaseo/server",
]) {
for (const required of ["@getpaseo/cli", "@getpaseo/server"]) {
expect(deps[required], `${required} must be declared in dependencies`).toBe("*");
}
expect(deps).not.toHaveProperty("@getpaseo/docker-workspace-runtime");
expect(deps).not.toHaveProperty("@getpaseo/srt-workspace-runtime");
});
it("launches the packaged macOS CLI through Helper instead of the main app executable", () => {
@@ -1,20 +0,0 @@
FROM node:22-bookworm-slim
ARG CODEX_VERSION=0.144.0
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates git procps zsh \
&& rm -rf /var/lib/apt/lists/* \
&& npm install --global "@openai/codex@${CODEX_VERSION}" \
&& npm cache clean --force \
&& mkdir -p /root/.codex
ENV CODEX_HOME=/root/.codex
COPY packages/docker-workspace-runtime/src/workload.mjs /opt/paseo-workspace-runtime/workload.mjs
COPY packages/server/src/server/workspace-helper/executable.mjs /opt/paseo-workspace-helper/executable.mjs
COPY packages/server/src/server/workspace-helper/executable.mjs /usr/bin/paseo-workspace-helper
COPY packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs /usr/local/bin/paseo-fixture-agent.mjs
RUN chmod 755 /usr/bin/paseo-workspace-helper /usr/local/bin/paseo-fixture-agent.mjs
WORKDIR /workspace
@@ -1,23 +0,0 @@
{
"name": "@getpaseo/docker-workspace-runtime",
"version": "0.3.1",
"private": true,
"bin": {
"paseo-docker-workspace-runtime": "./dist/index.js"
},
"type": "module",
"main": "./dist/index.js",
"scripts": {
"clean": "node ../../scripts/clean-package-dist.mjs",
"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"
},
"dependencies": {
"@getpaseo/workspace-runtime-contract": "*"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.9.3"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,60 +0,0 @@
import { spawn, spawnSync } from "node:child_process";
import { rmSync, writeFileSync } from "node:fs";
const encoded = process.env.PASEO_RUNTIME_EXEC;
if (!encoded) throw new Error("PASEO_RUNTIME_EXEC is required");
const request = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
delete process.env.PASEO_RUNTIME_EXEC;
const execId = process.env.PASEO_RUNTIME_EXEC_ID;
delete process.env.PASEO_RUNTIME_EXEC_ID;
if (!execId || !/^[a-f0-9]+$/.test(execId)) throw new Error("PASEO_RUNTIME_EXEC_ID is required");
const pidFile = `/tmp/paseo-runtime-exec-${execId}.pid`;
const readyFifo = `/tmp/paseo-runtime-exec-${execId}.ready`;
const doneFile = `/tmp/paseo-runtime-exec-${execId}.done`;
if (request.stdio?.kind === "pty") {
const resized = spawnSync(
"stty",
["cols", String(request.stdio.cols), "rows", String(request.stdio.rows)],
{ stdio: ["inherit", "inherit", "inherit"] },
);
if (resized.status !== 0) throw new Error("Runtime PTY initial resize failed");
}
const child = spawn(request.argv[0], request.argv.slice(1), {
cwd: request.cwd,
env: request.env,
detached: request.stdio?.kind !== "pty",
stdio: ["inherit", "inherit", "inherit"],
});
if (!child.pid) throw new Error("Runtime workload did not start");
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
process.on(signal, () => {
if (!child.pid) return;
try {
process.kill(-child.pid, signal);
} catch {
child.kill(signal);
}
});
}
writeFileSync(pidFile, `${process.pid} ${child.pid}\n`);
writeFileSync(readyFifo, `${execId}\n`);
child.once("error", (error) => {
rmSync(pidFile, { force: true });
writeFileSync(doneFile, "done");
process.stderr.write(`${error.message}\n`);
process.exitCode = 127;
});
child.once("exit", (code, signal) => {
rmSync(pidFile, { force: true });
writeFileSync(doneFile, "done");
if (signal) {
process.removeAllListeners(signal);
process.kill(process.pid, signal);
return;
}
process.exitCode = code ?? 1;
});
@@ -1,18 +0,0 @@
{
"name": "@getpaseo/fixture-workspace-runtime",
"version": "0.3.1",
"private": true,
"description": "External command-runtime contract fixture",
"bin": {
"paseo-fixture-workspace-runtime": "./src/index.mjs"
},
"type": "module",
"scripts": {
"build": "node --check src/index.mjs",
"typecheck": "node --check src/index.mjs"
},
"dependencies": {
"@getpaseo/workspace-runtime-contract": "*",
"node-pty": "1.2.0-beta.15"
}
}
+3 -2
View File
@@ -6,6 +6,7 @@
"dist/server",
"dist/src",
"dist/scripts",
"!dist/server/server/workspace-helper",
"!dist/**/*.map",
"README.md",
".env.example"
@@ -38,7 +39,7 @@
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "npm run build:lib && npm run build:scripts",
"build:clean": "npm run clean && npm run build",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs'); fs.copyFileSync('src/server/workspace-helper/executable.mjs','dist/server/server/workspace-helper/executable.mjs');\"",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.copyFileSync('src/terminal/terminal-ts-loader.mjs','dist/server/terminal/terminal-ts-loader.mjs');\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build:clean && npm --prefix ../.. run build:daemon-web-ui",
"start": "node dist/scripts/supervisor-entrypoint.js",
@@ -58,7 +59,6 @@
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
"test:integration:local": "vitest run local.e2e.test.ts",
"test:workspace-runtime:docker": "vitest run --maxWorkers=1 src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "vitest run e2e.test.ts --maxWorkers=1 --exclude \"**/*.real.e2e.test.ts\" --exclude \"**/*.local.e2e.test.ts\"",
@@ -76,6 +76,7 @@
"@getpaseo/highlight": "0.3.1",
"@getpaseo/protocol": "0.3.1",
"@getpaseo/relay": "0.3.1",
"@getpaseo/workspace-helper": "0.3.1",
"@getpaseo/workspace-runtime-contract": "0.3.1",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -13,11 +13,11 @@ import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { bindProviderWorkspace } from "./workspace/index.js";
const fixtureAgent = new URL(
"../../test-utils/fixtures/workspace-runtime-acp-agent.mjs",
"../../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs",
import.meta.url,
);
const fixtureRuntime = fileURLToPath(
new URL("../../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
new URL("../../../../../../runtimes/fixture/src/index.mjs", import.meta.url),
);
const posixDescribe = describe.runIf(process.platform !== "win32");
@@ -12,7 +12,7 @@ import type {
WorkspaceProcessPurpose,
WorkspaceRuntimeProviderCapability,
} from "../../../workspace-runtime/index.js";
import type { WorkspaceDirectory } from "../../../workspace-helper/index.js";
import type { WorkspaceDirectory } from "@getpaseo/workspace-helper";
export interface ProviderPlacementPolicy {
environment:
+1 -1
View File
@@ -1321,7 +1321,7 @@ export async function createPaseoDaemon(
getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
serviceProxyPublicBaseUrl,
onScriptsChanged: null,
runWorkspaceSetup: (workspaceId) => workspaceRuntime.runSetup(workspaceId),
bindWorkspaceRuntime: (workspaceId) => workspaceRuntime.bind(workspaceId),
},
input,
serviceOptions,
+1 -1
View File
@@ -94,7 +94,7 @@ describe("server config", () => {
const stateDirectory = path.join(paseoHome, "runtime-state");
await Promise.all([mkdir(source), mkdir(stateDirectory)]);
const fixtureExecutable = fileURLToPath(
new URL("../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
new URL("../../../../runtimes/fixture/src/index.mjs", import.meta.url),
);
const configPath = path.join(paseoHome, "config.json");
await writeFile(
@@ -6,6 +6,7 @@ import {
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
readdirSync,
rmSync,
writeFileSync,
@@ -14,6 +15,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { FileVersion } from "@getpaseo/protocol/messages";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
@@ -30,13 +32,13 @@ import {
const FIXTURE_PROVIDER = "workspace-runtime-fixture";
const FIXTURE_MODEL = "fixture-model";
const fixtureAgentPath = fileURLToPath(
new URL("../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url),
new URL(
"../../../../../runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs",
import.meta.url,
),
);
const fixtureRuntimePath = fileURLToPath(
new URL("../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
);
const workspaceHelperPath = fileURLToPath(
new URL("../workspace-helper/executable.mjs", import.meta.url),
new URL("../../../../../runtimes/fixture/src/index.mjs", import.meta.url),
);
interface CharacterizedWorkspace {
@@ -117,6 +119,63 @@ function createRepository(): string {
return repo;
}
function createBarrierRepository(mode: "complete" | "fail" = "complete"): string {
const repo = createRepository();
writeFileSync(
path.join(repo, "setup-barrier.mjs"),
mode === "complete"
? `import { existsSync, writeFileSync } from "node:fs";
process.stdout.write("setup-streamed-before-release\\n");
writeFileSync("setup-environment.json", JSON.stringify({
custom: process.env.PASEO_SETUP_CUSTOM_INHERITED,
supervised: process.env.PASEO_SUPERVISED,
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
bashEnv: process.env.BASH_ENV,
home: process.env.HOME,
path: process.env.PATH,
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,
}));
const timer = setInterval(() => {
if (!existsSync("release-setup")) return;
clearInterval(timer);
writeFileSync("setup-after-release.txt", "completed\\n");
}, 10);
`
: `process.stdout.write("setup-failed-after-publication\\n"); process.exit(7);\n`,
);
writeFileSync(
path.join(repo, "paseo.json"),
JSON.stringify({
worktree: { setup: [`${JSON.stringify(process.execPath)} setup-barrier.mjs`] },
}),
);
execFileSync("git", ["add", "."], { cwd: repo, stdio: "pipe" });
execFileSync("git", ["commit", "--amend", "--no-edit"], { cwd: repo, stdio: "pipe" });
return repo;
}
function waitForRawMessage<T extends SessionOutboundMessage>(
selectedClient: DaemonClient,
predicate: (message: SessionOutboundMessage) => message is T,
): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error("Timed out waiting for public daemon message"));
}, 15_000);
const unsubscribe = selectedClient.subscribeRawMessages((message) => {
if (!predicate(message)) return;
clearTimeout(timeout);
unsubscribe();
resolve(message);
});
});
}
async function createCharacterizedWorkspace(kind: "local" | "worktree") {
const repo = createRepository();
const result = await client.createWorkspace({
@@ -452,8 +511,176 @@ describe("current workspace runtime journeys", () => {
}
}
}, 120_000);
test("owned worktree publishes, streams setup, and runs an agent before setup releases", async () => {
const repo = createBarrierRepository();
const bashEnvMarker = path.join(repo, "bash-env-was-sourced");
const bashEnvStartup = path.join(repo, "setup-bash-env.sh");
writeFileSync(bashEnvStartup, `printf sourced > ${JSON.stringify(bashEnvMarker)}\n`);
const originalCustom = process.env.PASEO_SETUP_CUSTOM_INHERITED;
const originalSupervised = process.env.PASEO_SUPERVISED;
const originalElectronRunAsNode = process.env.ELECTRON_RUN_AS_NODE;
const originalBashEnv = process.env.BASH_ENV;
process.env.PASEO_SETUP_CUSTOM_INHERITED = "custom daemon value with spaces";
process.env.PASEO_SUPERVISED = "must-be-sanitized";
process.env.ELECTRON_RUN_AS_NODE = "must-be-sanitized";
process.env.BASH_ENV = bashEnvStartup;
const messages: SessionOutboundMessage[] = [];
const unsubscribe = client.subscribeRawMessages((message) => messages.push(message));
let workspace: CharacterizedWorkspace | undefined;
try {
const created = await client.createWorkspace({
runtimeId: "worktree",
source: {
kind: "worktree",
cwd: repo,
action: "branch-off",
branchName: "causal-worktree",
worktreeSlug: "causal-worktree",
baseBranch: "main",
},
});
if (!created.workspace?.workspaceDirectory) {
throw new Error(created.error ?? "Causal worktree creation failed");
}
workspace = {
id: created.workspace.id,
projectId: created.workspace.projectId,
cwd: created.workspace.workspaceDirectory,
kind: "worktree",
};
expect(existsSync(path.join(workspace.cwd, "release-setup"))).toBe(false);
expect(
messages.some(
(message) =>
message.type === "workspace.create.response" &&
message.payload.workspace?.id === workspace?.id,
),
).toBe(true);
expect(
messages.some(
(message) =>
message.type === "workspace_update" &&
message.payload.kind === "upsert" &&
message.payload.workspace.id === workspace?.id,
),
).toBe(true);
const isRunningSetup = (
message: SessionOutboundMessage,
): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
message.type === "workspace_setup_progress" &&
message.payload.workspaceId === workspace?.id &&
message.payload.status === "running" &&
message.payload.detail.commands.some(
(command) =>
command.status === "running" && command.log.includes("setup-streamed-before-release"),
);
const running =
messages.find(isRunningSetup) ??
(await waitForRawMessage(
client,
(
message,
): message is Extract<SessionOutboundMessage, { type: "workspace_setup_progress" }> =>
isRunningSetup(message),
));
expect(running.payload.detail.worktreePath).toBe(workspace.cwd);
expect(running.payload.detail.commands[0]?.cwd).toBe(workspace.cwd);
expect(existsSync(path.join(workspace.cwd, "setup-after-release.txt"))).toBe(false);
const setupEnvironment = JSON.parse(
await readWorkspaceTextFile(workspace.id, workspace.cwd, "setup-environment.json"),
) as Record<string, string | undefined>;
expect(setupEnvironment).toMatchObject({
custom: "custom daemon value with spaces",
home: process.env.HOME,
path: process.env.PATH,
source: realpathSync(repo),
root: realpathSync(repo),
worktree: workspace.cwd,
branch: "causal-worktree",
port: expect.stringMatching(/^\d+$/u),
});
expect(setupEnvironment.supervised).toBeUndefined();
expect(setupEnvironment.electronRunAsNode).toBeUndefined();
expect(setupEnvironment.bashEnv).toBeUndefined();
expect(existsSync(bashEnvMarker)).toBe(false);
await client.refreshProvidersSnapshot({
cwd: workspace.cwd,
workspaceId: workspace.id,
providers: [FIXTURE_PROVIDER],
});
const agent = await client.createAgent({
provider: FIXTURE_PROVIDER,
model: FIXTURE_MODEL,
cwd: workspace.cwd,
workspaceId: workspace.id,
title: "agent before setup release",
});
await client.sendMessage(agent.id, "agent-before-release");
expect((await client.waitForFinish(agent.id, 30_000)).status).toBe("idle");
await expect(
readWorkspaceTextFile(workspace.id, workspace.cwd, "stdio-agent-output.txt"),
).resolves.toBe("agent-before-release\n");
expect(existsSync(path.join(workspace.cwd, "release-setup"))).toBe(false);
writeFileSync(path.join(workspace.cwd, "release-setup"), "release\n");
await expect
.poll(() => client.fetchWorkspaceSetupStatus(workspace!.id), { timeout: 15_000 })
.toMatchObject({ snapshot: { status: "completed", error: null } });
await expect(
readWorkspaceTextFile(workspace.id, workspace.cwd, "setup-after-release.txt"),
).resolves.toBe("completed\n");
} finally {
restoreProcessEnvironment("PASEO_SETUP_CUSTOM_INHERITED", originalCustom);
restoreProcessEnvironment("PASEO_SUPERVISED", originalSupervised);
restoreProcessEnvironment("ELECTRON_RUN_AS_NODE", originalElectronRunAsNode);
restoreProcessEnvironment("BASH_ENV", originalBashEnv);
unsubscribe();
if (workspace) await client.removeProject(workspace.projectId).catch(() => undefined);
}
}, 120_000);
test("setup failure after publication leaves the worktree usable", async () => {
const repo = createBarrierRepository("fail");
const created = await client.createWorkspace({
runtimeId: "worktree",
source: {
kind: "worktree",
cwd: repo,
action: "branch-off",
branchName: "failed-setup-worktree",
worktreeSlug: "failed-setup-worktree",
baseBranch: "main",
},
});
if (!created.workspace?.workspaceDirectory) throw new Error(created.error ?? "create failed");
const workspace = {
id: created.workspace.id,
projectId: created.workspace.projectId,
cwd: created.workspace.workspaceDirectory,
kind: "worktree" as const,
};
try {
await expect
.poll(() => client.fetchWorkspaceSetupStatus(workspace.id), { timeout: 15_000 })
.toMatchObject({ snapshot: { status: "failed" } });
await expectFileEditAndWatch(workspace);
await expectGitObservation(workspace);
await expectTerminalCommand(workspace);
} finally {
await client.removeProject(workspace.projectId).catch(() => undefined);
}
}, 120_000);
});
function restoreProcessEnvironment(name: string, value: string | undefined): void {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
test("catalog selection creates through a configured runtime while omission remains local", async () => {
const root = mkdtempSync(path.join(tmpdir(), "workspace-runtime-selection-"));
cleanupRoots.push(root);
@@ -473,7 +700,6 @@ test("catalog selection creates through a configured runtime while omission rema
type: "command",
label: "Fixture",
command: [process.execPath, fixtureRuntimePath],
helperCommand: [process.execPath, workspaceHelperPath],
options: { stateDirectory },
},
},
@@ -488,7 +714,6 @@ test("catalog selection creates through a configured runtime while omission rema
runtimes: [
{ runtimeId: "local", builtin: true, requiresGitProject: false },
{ runtimeId: "worktree", builtin: true, requiresGitProject: true },
{ runtimeId: "docker", builtin: true, requiresGitProject: true },
{
runtimeId: "fixture",
builtin: false,
@@ -554,7 +779,6 @@ test("provider probes match user workspace snapshots for every host-backed runti
type: "command",
label: "Fixture",
command: [process.execPath, fixtureRuntimePath],
helperCommand: [process.execPath, workspaceHelperPath],
options: { stateDirectory, materializeRoot },
},
},
@@ -121,7 +121,7 @@ async function createPaseoWorktreeWithPriority(
intent: plan.intent,
workspace: persistedWorkspace,
repoRoot: plan.repoRoot,
created: true,
created: placement.materializedFreshContent,
};
} catch (error) {
await deps.workspaceRuntime
@@ -171,7 +171,6 @@ export function createService(options: ServiceOptions): ProviderProbeService {
},
placement: { kind: "existing" },
purpose: "provider-probe",
setupFromPaseoConfig: false,
});
const created = await options.store.get(workspaceId);
if (!created) throw new Error(`Provider probe record not found after create: ${workspaceId}`);
@@ -55,7 +55,6 @@ posixDescribe("provider probe service", () => {
},
placement: { kind: "existing" },
purpose: "provider-probe",
setupFromPaseoConfig: false,
});
await expect(fixture.service.records.resolveRuntimeId(firstResult.workspaceId)).resolves.toBe(
"fixture",
+52 -12
View File
@@ -244,6 +244,7 @@ import {
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
runWorktreeSetupInBackground,
} from "./worktree-session.js";
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
@@ -5526,8 +5527,9 @@ export class Session {
await this.workspaceRegistry.remove(workspace.workspaceId);
throw new Error(`Project not found after workspace creation: ${workspace.projectId}`);
}
let materializedFreshContent = false;
try {
await workspaceRuntime.create({
const placement = await workspaceRuntime.create({
workspaceId: workspace.workspaceId,
runtimeId,
project: {
@@ -5535,8 +5537,8 @@ export class Session {
source: projectRuntimeSource(project),
},
placement: { kind: "existing" },
setupFromPaseoConfig: true,
});
materializedFreshContent = placement.materializedFreshContent;
} catch (error) {
await this.workspaceRegistry.remove(workspace.workspaceId);
throw error;
@@ -5560,14 +5562,52 @@ export class Session {
descriptor,
request.firstAgentContext ? "running" : undefined,
);
void this.workspaceGitService
.getSnapshot(workspace.cwd, { force: true, includeForge: true, reason: "open_project" })
.catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: workspace.cwd },
"Background snapshot refresh failed after workspace.create",
);
});
if (materializedFreshContent) {
this.workspaceSetupRuntime.start(workspace.workspaceId, (signal) =>
runWorktreeSetupInBackground(
{
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
this.workspaceSetupSnapshots.set(workspaceId, snapshot);
},
emit: (message) => this.emit(message),
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
serviceProxy: this.serviceProxy,
scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
onScriptsChanged: (workspaceId, workspaceDirectory) => {
this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory);
},
bindWorkspaceRuntime: (workspaceId) => workspaceRuntime.bind(workspaceId),
},
{
requestCwd: materializedWorkspace.cwd,
repoRoot: materializedWorkspace.cwd,
workspaceId: workspace.workspaceId,
worktree: {
branchName: "",
worktreePath: materializedWorkspace.cwd,
},
shouldBootstrap: true,
slug: workspace.workspaceId,
worktreePath: materializedWorkspace.cwd,
workspaceCwd: materializedWorkspace.cwd,
},
signal,
),
);
}
void this.warmWorkspaceGitDataForWorkspace(materializedWorkspace).catch((error) => {
this.sessionLogger.warn(
{ err: error, workspaceId: materializedWorkspace.workspaceId },
"Background snapshot refresh failed after workspace.create",
);
});
if (request.firstAgentContext) {
const firstAgentContext = request.firstAgentContext;
this.workspaceAutoName.scheduleForDirectory(
@@ -6177,9 +6217,9 @@ export class Session {
onScriptsChanged: (workspaceId, workspaceDirectory) => {
this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory);
},
runWorkspaceSetup: async (workspaceId, signal) => {
bindWorkspaceRuntime: async (workspaceId) => {
if (!this.workspaceRuntime) throw new Error("Workspace runtime service was not composed");
await this.workspaceRuntime.runSetup(workspaceId, signal);
return this.workspaceRuntime.bind(workspaceId);
},
},
input,
@@ -1140,11 +1140,12 @@ test("create_agent_request launches from an exact subdirectory in a created work
resolveProviderWorkspace: async (workspaceId) => {
const workspace = await workspaceRegistry.get(workspaceId);
if (!workspace?.runtime) return null;
const runtime = await workspaceRuntime.bind(workspaceId);
return bindProviderWorkspace({
runtime: await workspaceRuntime.bind(workspaceId),
runtime,
cwd: ".",
policy: resolveProviderPlacementPolicy({
runtimeId: workspace.runtime.runtimeId,
capability: runtime.provider,
hostEnvironment: process.env,
}),
});
@@ -9567,7 +9568,12 @@ test("workspace.create.request materializes an explicit project's git source ins
...workspace,
runtime: { runtimeId: input.runtimeId },
});
return { workspaceId: input.workspaceId, runtimeId: input.runtimeId, cwd: runtimeCwd };
return {
workspaceId: input.workspaceId,
runtimeId: input.runtimeId,
cwd: runtimeCwd,
materializedFreshContent: false,
};
};
await session.handleMessage({
@@ -16,7 +16,7 @@ import type {
SessionInboundMessage,
SessionOutboundMessage,
} from "../../messages.js";
import type { WorkspaceFileKind, WorkspaceFiles } from "../../workspace-helper/index.js";
import type { WorkspaceFileKind, WorkspaceFiles } from "@getpaseo/workspace-helper";
import type { WorkspaceRuntimeService } from "../../workspace-runtime/index.js";
import type { WorkspaceRegistry } from "../../workspace-registry.js";
import { FileUploadStore } from "../../file-upload/index.js";
@@ -26,8 +26,10 @@ import {
} from "../../script-status-projection.js";
import { deriveProjectServiceSlug, deriveProjectSlug } from "../../workspace-git-metadata.js";
import type { PaseoServicePortAllocation } from "@getpaseo/protocol/paseo-config-schema";
import { PaseoConfigSchema, type PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
import type { WorkspaceRuntimeService } from "../../workspace-runtime/index.js";
import { parsePaseoConfigContentsOrThrow } from "../../../utils/worktree.js";
import { resolvePaseoConfigPath } from "../../../utils/paseo-config-file.js";
type WorkspaceScriptsPayload = WorkspaceDescriptorPayload["scripts"];
@@ -76,7 +78,10 @@ export async function readWorkspacePaseoConfig(input: {
const file = await files.read("paseo.json");
const chunks: Buffer[] = [];
for await (const chunk of file.chunks) chunks.push(Buffer.from(chunk));
return PaseoConfigSchema.parse(JSON.parse(Buffer.concat(chunks).toString("utf8")));
return parsePaseoConfigContentsOrThrow(
Buffer.concat(chunks),
resolvePaseoConfigPath(workspace.cwd),
);
} catch (error) {
logger.warn(
{ workspaceId: workspace.workspaceId, err: error },
@@ -1,5 +1,5 @@
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
@@ -280,18 +280,36 @@ test.skipIf(process.platform === "win32")(
async () => {
const repoDir = createGitRepo();
const setupStartedPath = path.join(repoDir, "setup-started");
const stopSetupPath = path.join(repoDir, "stop-setup");
const descendantPidPath = path.join(repoDir, "setup-descendant.pid");
writeFileSync(
path.join(repoDir, "setup-descendant.mjs"),
`process.on("SIGTERM", () => {}); setInterval(() => {}, 1000);\n`,
);
writeFileSync(
path.join(repoDir, "setup-owner.mjs"),
`import { spawn } from "node:child_process";
import { writeFileSync } from "node:fs";
import path from "node:path";
const source = process.env.PASEO_SOURCE_CHECKOUT_PATH;
const child = spawn(process.execPath, ["setup-descendant.mjs"], { stdio: "ignore" });
writeFileSync(path.join(source, "setup-descendant.pid"), String(child.pid));
writeFileSync(path.join(source, "setup-started"), "started");
process.on("SIGTERM", () => {});
setInterval(() => {}, 1000);
`,
);
writeFileSync(
path.join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [
`node -e "const fs=require('fs'),path=require('path');const source=process.env.PASEO_SOURCE_CHECKOUT_PATH;const worktree=process.env.PASEO_WORKTREE_PATH;const target=path.join(worktree,'node_modules/react-native-svg/lib/typescript');fs.writeFileSync(path.join(source,'setup-started'),'started');while(!fs.existsSync(path.join(source,'stop-setup'))){try{fs.mkdirSync(target,{recursive:true});fs.writeFileSync(path.join(target,'active'),String(Date.now()))}catch{}}"`,
],
setup: [`${JSON.stringify(process.execPath)} setup-owner.mjs`],
},
}),
);
execFileSync("git", ["add", "paseo.json"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["add", "paseo.json", "setup-owner.mjs", "setup-descendant.mjs"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync(
"git",
["-c", "commit.gpgsign=false", "commit", "-m", "add active worktree setup"],
@@ -313,8 +331,13 @@ test.skipIf(process.platform === "win32")(
try {
await expect.poll(() => existsSync(setupStartedPath), { timeout: 10000 }).toBe(true);
const descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
expect(() => process.kill(descendantPid, 0)).not.toThrow();
const archive = await ctx.client.archiveWorkspace(workspace.id);
expect(() => process.kill(descendantPid, 0)).toThrow(
expect.objectContaining({ code: "ESRCH" }),
);
const retry = await ctx.client.archiveWorkspace(workspace.id);
expect({ archiveError: archive.error, retryError: retry.error }).toEqual({
@@ -324,7 +347,14 @@ test.skipIf(process.platform === "win32")(
expect((await activeWorkspaceIds()).has(workspace.id)).toBe(false);
expect(existsSync(workspace.workspaceDirectory)).toBe(true);
} finally {
writeFileSync(stopSetupPath, "stop\n");
if (existsSync(descendantPidPath)) {
const descendantPid = Number.parseInt(readFileSync(descendantPidPath, "utf8"), 10);
try {
process.kill(descendantPid, "SIGKILL");
} catch {
// The production archive path reaped the setup process tree.
}
}
}
},
60000,
@@ -11,10 +11,7 @@ import { observeWorkspaceGit } from "./workspace-git-observation.js";
import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js";
const fixtureExecutable = fileURLToPath(
new URL("../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
);
const helperExecutable = fileURLToPath(
new URL("./workspace-helper/executable.mjs", import.meta.url),
new URL("../../../../runtimes/fixture/src/index.mjs", import.meta.url),
);
const cleanupRoots: string[] = [];
@@ -471,7 +468,6 @@ test("selected workspace Git reads and mutations stay inside its command runtime
fixture: {
type: "command",
command: [process.execPath, fixtureExecutable],
helperCommand: [process.execPath, helperExecutable],
options: { stateDirectory, recordLaunchInWorkspace: false },
},
},
@@ -615,7 +611,6 @@ test("selected commit history highlighting never reads a deleted file from the h
fixture: {
type: "command",
command: [process.execPath, fixtureExecutable],
helperCommand: [process.execPath, helperExecutable],
options: { stateDirectory, recordLaunchInWorkspace: false },
},
},
@@ -677,7 +672,6 @@ test("selected workspaces with the same public cwd keep Git state, mutations, an
fixture: {
type: "command",
command: [process.execPath, fixtureExecutable],
helperCommand: [process.execPath, helperExecutable],
options: { stateDirectory, recordLaunchInWorkspace: false },
},
},
@@ -71,7 +71,7 @@ export function createCommandRuntime(
encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, {
protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION,
runtimeInstanceId,
input,
input: input ? commandCreateInput(input) : undefined,
options: config.options ?? {},
}),
);
@@ -189,16 +189,40 @@ export function createCommandRuntime(
}
}
function commandReady(
state: WorkspaceDriverState,
placement: { cwd: string; hostVisiblePath?: string } | undefined,
) {
function commandReady(state: WorkspaceDriverState, placement: { cwd: string } | undefined) {
if (!placement) {
throw new Error("Workspace runtime did not return public placement");
}
return { state, placement };
}
function commandCreateInput(input: WorkspaceDriverCreateInput) {
return {
...input,
project: {
...input.project,
source:
input.project.source.kind === "host-directory"
? { kind: "directory" as const, path: input.project.source.path }
: input.project.source,
},
purpose: input.purpose === "provider-probe" ? ("discovery" as const) : undefined,
};
}
function commandPurpose(input: WorkspaceDriverSpawnInput["purpose"]) {
switch (input.kind) {
case "provider-probe":
return { kind: "discovery" as const };
case "agent":
case "terminal":
case "workspace-script":
return { kind: input.kind };
default:
return input;
}
}
function assertProtocolVersion(runtimeId: string, value: unknown): void {
const version =
typeof value === "object" && value !== null && "protocolVersion" in value
@@ -318,7 +342,7 @@ function spawnCommandPty(
argv: input.argv,
cwd: input.cwd,
env: input.env,
purpose: input.purpose,
purpose: commandPurpose(input.purpose),
options,
runtimeInstanceId,
execId,
@@ -642,7 +666,7 @@ function spawnCommandProcess(
argv: input.argv,
cwd: input.cwd,
env: input.env,
purpose: input.purpose,
purpose: commandPurpose(input.purpose),
options,
runtimeInstanceId,
execId,
@@ -106,6 +106,8 @@ export interface WorkspaceRuntimeDriver {
};
readonly scriptTerminal: import("../index.js").WorkspaceScriptTerminal;
readonly provider: import("../index.js").WorkspaceRuntimeProviderCapability;
/** Private setup-only base environment owned by this execution boundary. */
setupEnvironment?(): Readonly<Record<string, string>>;
create(input: WorkspaceDriverCreateInput): Promise<WorkspaceDriverCreation>;
inspect(workspaceId: WorkspaceId): Promise<WorkspaceDriverInspection>;
spawn(input: WorkspaceDriverSpawnInput): Promise<WorkspaceDriverProcess>;
@@ -1,6 +1,6 @@
import type { BoundWorkspaceRuntime } from "../../index.js";
import type { WorkspaceRuntimeDriver } from "../../drivers/index.js";
import type { WorkspaceFilesSubscription } from "../../../workspace-helper/index.js";
import type { WorkspaceFilesSubscription } from "@getpaseo/workspace-helper";
import { registerGitCommonObservationCapability } from "./capability.js";
interface GitObservation {
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { WorkspaceFiles } from "../workspace-helper/index.js";
import type { WorkspaceFiles } from "@getpaseo/workspace-helper";
import { createCommandRuntimeAdapter } from "./command/index.js";
import { createHostGitObservationOwner } from "./internal/host-git-observation.js";
@@ -76,8 +76,6 @@ export interface CreateWorkspaceInput {
project: { id: string; source: WorkspaceProjectSource };
placement: WorkspacePlacement;
purpose?: "provider-probe";
setup?: readonly WorkspaceSetupCommand[];
setupFromPaseoConfig?: boolean;
markFirstAgentBranchAutoName?: boolean;
seedPaseoConfigFrom?: string;
}
@@ -145,7 +143,6 @@ export interface WorkspaceRuntimeService {
files(workspaceId: string): WorkspaceFiles;
inspect(workspaceId: string): Promise<WorkspaceRuntimeInspection>;
requireHostVisiblePath(workspaceId: string): Promise<string>;
runSetup(workspaceId: string, signal?: AbortSignal): Promise<void>;
pause(workspaceId: string): Promise<void>;
resume(workspaceId: string): Promise<void>;
archive(workspaceId: string): Promise<void>;
@@ -165,11 +162,15 @@ export interface WorkspaceRuntimePlacement {
runtimeId: string;
cwd: string;
hostVisiblePath?: string;
materializedFreshContent: boolean;
}
export type WorkspaceRuntimeInspection =
| { status: "missing" | "error" }
| ({ status: "paused" | "ready" } & Omit<WorkspaceRuntimePlacement, "workspaceId" | "runtimeId">);
| ({ status: "paused" | "ready" } & Omit<
WorkspaceRuntimePlacement,
"workspaceId" | "runtimeId" | "materializedFreshContent"
>);
export interface WorkspaceRuntimeRecordStore {
resolveRuntimeId(workspaceId: string): Promise<string | null>;
@@ -1,9 +1,7 @@
import { fileURLToPath } from "node:url";
import { buildSelfNodeCommand } from "../../paseo-env.js";
import { workspaceHelperExecutable } from "@getpaseo/workspace-helper";
const executable = fileURLToPath(new URL("../../workspace-helper/executable.mjs", import.meta.url));
const launch = buildSelfNodeCommand([executable]);
const launch = buildSelfNodeCommand([workspaceHelperExecutable]);
export const hostWorkspaceHelper = {
command: [launch.command, ...launch.args] as readonly [string, ...string[]],
@@ -17,16 +17,14 @@ import type {
WorkspaceFiles,
WorkspaceFilesSubscription,
WorkspaceWatchEvent,
} from "../../workspace-helper/index.js";
import {
bindWorkspaceHelper,
type WorkspaceFilesOwner,
} from "../../workspace-helper/internal/integration/index.js";
} from "@getpaseo/workspace-helper";
import { bindWorkspaceHelper, type WorkspaceFilesOwner } from "@getpaseo/workspace-helper";
import { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema";
import {
createGitCommonObservationCoordinator,
type ObservationRebindTransaction,
} from "../git-observation/internal/integration.js";
import { findFreePort } from "../../service-proxy.js";
const gracefulStopMilliseconds = 1_000;
const forcedStopMilliseconds = 1_000;
@@ -51,6 +49,7 @@ export function createService(
>();
const unavailableFiles = new Map<string, "paused" | "recovering" | "destroyed">();
const boundRuntimes = new Map<string, { runtimeId: string; runtime: BoundWorkspaceRuntime }>();
const setupAugmentations = new Map<string, Readonly<Record<string, string>>>();
const gitCommonObservations = createGitCommonObservationCoordinator();
function requireRegistered(runtimeId: string): WorkspaceRuntimeDriver {
@@ -74,7 +73,18 @@ export function createService(
if (inspection.status !== "ready") {
throw new Error(`Workspace runtime is ${inspection.status}: ${input.workspaceId}`);
}
const runtimeProcess = await driver.spawn({ ...input, stdio: { kind: "pipes" } });
const runtimeProcess = await driver.spawn({
...input,
env: {
...(input.purpose.kind === "setup" ? driver.setupEnvironment?.() : {}),
...input.env,
...(input.purpose.kind === "setup" || driver.provider.environment === "isolated"
? inspection.state.lifecycleEnvironment
: {}),
...(input.purpose.kind === "setup" ? setupAugmentations.get(input.workspaceId) : {}),
},
stdio: { kind: "pipes" },
});
if (runtimeProcess.kind !== "pipes") {
throw new Error(`Workspace runtime returned PTY mode for a pipe launch: ${driver.id}`);
}
@@ -93,6 +103,12 @@ export function createService(
}
const runtimeProcess = await driver.spawn({
...input,
env: {
...input.env,
...(driver.provider.environment === "isolated"
? inspection.state.lifecycleEnvironment
: {}),
},
stdio: { kind: "pty", rows: input.rows, cols: input.cols, term: input.term },
});
if (runtimeProcess.kind !== "pty") {
@@ -192,33 +208,18 @@ export function createService(
await helper.close();
}
}
if (ready.materializedFreshContent && input.purpose !== "provider-probe") {
const setupCommands = input.setupFromPaseoConfig
? await readConfiguredSetupCommands(input.workspaceId)
: (input.setup ?? []);
for (const command of setupCommands) {
const process = await runWithDriver(driver, {
workspaceId: input.workspaceId,
...command,
purpose: { kind: "setup" },
});
process.stdin.end();
const [, , exit] = await Promise.all([
drain(process.stdout),
drain(process.stderr),
process.exited,
]);
if (exit.code !== 0 || exit.signal !== null) {
throw new Error(`Workspace setup failed: ${exit.code ?? exit.signal}`);
}
}
}
await records.persistRuntimeId(input.workspaceId, input.runtimeId, ready.placement);
if (ready.materializedFreshContent && input.purpose !== "provider-probe") {
setupAugmentations.set(input.workspaceId, {
PASEO_WORKTREE_PORT: String(await findFreePort()),
});
}
unavailableFiles.delete(input.workspaceId);
return {
workspaceId: input.workspaceId,
runtimeId: input.runtimeId,
...ready.placement,
materializedFreshContent: ready.materializedFreshContent,
};
} catch (error) {
if (ownsNewResource) {
@@ -300,32 +301,6 @@ export function createService(
}
return inspection.placement.hostVisiblePath;
},
async runSetup(workspaceId, signal) {
const commands = await readConfiguredSetupCommands(workspaceId);
for (const command of commands) {
if (signal?.aborted) throw signal.reason;
const process = await sequence(workspaceId, async () =>
runWithDriver(await resolve(workspaceId), {
workspaceId,
...command,
purpose: { kind: "setup" },
}),
);
const abort = () => process.kill("SIGTERM");
signal?.addEventListener("abort", abort, { once: true });
process.stdin.end();
const [stdout, stderr, exit] = await Promise.all([
drainText(process.stdout),
drainText(process.stderr),
process.exited,
]).finally(() => signal?.removeEventListener("abort", abort));
if (exit.code !== 0 || exit.signal !== null) {
throw new Error(
`Workspace setup failed (${exit.code ?? exit.signal}): ${stderr || stdout}`,
);
}
}
},
async pause(workspaceId) {
await sequence(workspaceId, async () => {
await pauseWithDriver(workspaceId, await resolve(workspaceId));
@@ -375,6 +350,7 @@ export function createService(
const runtimeId = await records.resolveRuntimeId(workspaceId);
if (!runtimeId) {
unavailableFiles.delete(workspaceId);
setupAugmentations.delete(workspaceId);
return;
}
if (!records.beginWorkspaceDeletion || !records.removeWorkspaceRecord) {
@@ -399,6 +375,7 @@ export function createService(
}
await records.removeWorkspaceRecord(workspaceId);
unavailableFiles.delete(workspaceId);
setupAugmentations.delete(workspaceId);
}
async function pauseWithDriver(
@@ -449,23 +426,6 @@ export function createService(
}
}
async function readConfiguredSetupCommands(workspaceId: string) {
const config = await readWorkspaceConfig(workspaceId);
const driver = await resolve(workspaceId);
const inspection = await driver.inspect(workspaceId);
if (inspection.status !== "ready") {
throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`);
}
const env = {
...lifecycleEnvironment(),
...inspection.state.lifecycleEnvironment,
};
return (config?.worktree?.setup ?? []).map((command) => ({
argv: lifecycleShellCommand(command),
env,
}));
}
async function readWorkspaceConfig(workspaceId: string) {
const files = await requireFiles(workspaceId);
const stat = await files.stat("paseo.json");
@@ -705,12 +665,6 @@ function noOpRebindTransaction(): ObservationRebindTransaction {
return { commit: async () => {}, rollback: async () => {} };
}
async function drain(stream: NodeJS.ReadableStream): Promise<void> {
for await (const _chunk of stream) {
// Setup output is consumed here so a verbose command cannot block on a full pipe.
}
}
async function drainText(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
let bytes = 0;
@@ -19,6 +19,8 @@ import { hostWorkspaceHelper } from "./host-helper.js";
import type { HostGitObservationOwner } from "./host-git-observation.js";
import { createRuntimeStateStore } from "./runtime-state.js";
import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata } from "../../../utils/worktree-metadata.js";
import { createExternalProcessEnv } from "../../paseo-env.js";
import { createStringCommandShellEnv } from "../../../utils/string-command-shell.js";
interface WorktreeRuntimeState {
workspaceId: string;
@@ -72,6 +74,7 @@ export function createWorktreeRuntime(options: {
environment: "inherit-sanitized-host",
sharedHostProviders: new Set(["opencode"]),
},
setupEnvironment: () => createStringCommandShellEnv(createExternalProcessEnv(process.env)),
async create(input: WorkspaceDriverCreateInput) {
const existing = await inspect(input.workspaceId);
if (existing.status === "ready" || existing.status === "paused") {
@@ -153,9 +156,10 @@ export function createWorktreeRuntime(options: {
sourceRoot,
lifecycle: "ready",
lifecycleEnvironment: {
PASEO_SOURCE_CHECKOUT_PATH: ".",
PASEO_ROOT_PATH: ".",
PASEO_WORKTREE_PATH: ".",
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
PASEO_SOURCE_CHECKOUT_PATH: sourceRoot,
PASEO_ROOT_PATH: sourceRoot,
PASEO_WORKTREE_PATH: worktree.worktreePath,
PASEO_BRANCH_NAME: worktree.branchName,
},
ownsWorktree: true,
@@ -7,8 +7,22 @@ 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");
for (const packagePath of [
"packages/cli/package.json",
"packages/desktop/package.json",
"packages/server/package.json",
]) {
const manifest = packageJson(packagePath);
expect(manifest.dependencies, packagePath).not.toHaveProperty(
"@getpaseo/docker-workspace-runtime",
);
expect(manifest.dependencies, packagePath).not.toHaveProperty(
"@getpaseo/srt-workspace-runtime",
);
expect(JSON.stringify(manifest.files ?? []), packagePath).not.toMatch(
/(?:docker|srt)-workspace-runtime|runtimes\/(?:docker|srt)/u,
);
}
const rootPackage = packageJson("package.json");
expect(rootPackage.scripts?.["build:server-deps"]).not.toContain("docker-workspace-runtime");
@@ -28,20 +42,37 @@ test("core owns only generic command runtime registration", () => {
}
});
test("Docker's bundled registration exists only at distribution composition roots", () => {
const packageName = "@getpaseo/docker-workspace-runtime";
test("production composition and release artifacts exclude runtime implementations", () => {
for (const relativePath of [
"packages/cli/src/commands/daemon/local-daemon.ts",
"packages/desktop/src/daemon/daemon-manager.ts",
"packages/desktop/electron-builder.yml",
"docker/base/Dockerfile",
]) {
expect(readFileSync(path.join(repositoryRoot, relativePath), "utf8"), relativePath).toContain(
packageName,
expect(readFileSync(path.join(repositoryRoot, relativePath), "utf8"), relativePath).not.toMatch(
/@getpaseo\/(?:docker|srt)-workspace-runtime|runtimes\/(?:docker|srt)/u,
);
}
const rootPackage = packageJson("package.json");
const releaseScripts = Object.entries(rootPackage.scripts ?? {})
.filter(([name]) => name.startsWith("release:"))
.map(([, command]) => command)
.join("\n");
expect(releaseScripts).not.toMatch(/(?:docker|srt)-workspace-runtime/u);
const dockerfile = readFileSync(path.join(repositoryRoot, "docker/base/Dockerfile"), "utf8");
expect(dockerfile).toContain(
"npm pack --workspace=@getpaseo/workspace-runtime-contract --pack-destination /tmp/paseo-packs",
);
expect(dockerfile).toContain(
"npm pack --workspace=@getpaseo/workspace-helper --pack-destination /tmp/paseo-packs",
);
});
function packageJson(relativePath: string): {
dependencies?: Record<string, string>;
files?: string[];
scripts?: Record<string, string>;
} {
return JSON.parse(readFileSync(path.join(repositoryRoot, relativePath), "utf8"));
@@ -10,10 +10,10 @@ import { afterEach, expect, test, vi } from "vitest";
import { createWorkspaceRuntimeService } from "./index.js";
const fixtureExecutable = fileURLToPath(
new URL("../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
new URL("../../../../../runtimes/fixture/src/index.mjs", import.meta.url),
);
const helperExecutable = fileURLToPath(
new URL("../workspace-helper/executable.mjs", import.meta.url),
new URL("../../../../../packages/workspace-helper/src/executable.mjs", import.meta.url),
);
const rebindHelperExecutable = fileURLToPath(
new URL("../test-utils/fixtures/workspace-helper-rebind-fixture.mjs", import.meta.url),
@@ -54,10 +54,7 @@ test("a trusted registered command is selected and receives secret launch data o
await readFile(path.join(fixture.source, ".runtime-launch.json"), "utf8"),
) as { argv: string[]; purpose: unknown };
expect(JSON.stringify(launch.argv)).not.toContain(secret);
expect(launch.purpose).toEqual({
kind: "workspace-script",
script: "secure-envelope-contract",
});
expect(launch.purpose).toEqual({ kind: "workspace-script" });
await expect(
fixture.service.create({
@@ -69,7 +66,7 @@ test("a trusted registered command is selected and receives secret launch data o
await fixture.service.destroy(fixture.workspaceId);
});
test("the fixture executable receives provider probe purpose through the strict lifecycle contract", async () => {
test("the fixture executable receives generic discovery purpose through the strict lifecycle contract", async () => {
const fixture = await createFixture("provider-probe-purpose");
await fixture.service.create({
...fixture.createInput,
@@ -83,10 +80,10 @@ test("the fixture executable receives provider probe purpose through the strict
workspaceId: fixture.workspaceId,
project: {
projectId: fixture.createInput.project.id,
source: fixture.createInput.project.source,
source: { kind: "directory", path: fixture.createInput.project.source.path },
},
placement: fixture.createInput.placement,
purpose: "provider-probe",
purpose: "discovery",
});
await fixture.service.destroy(fixture.workspaceId);
});
@@ -861,6 +858,53 @@ test("an existing runtime selection cannot be switched before target driver disp
await fixture.service.destroy(fixture.workspaceId);
});
test("isolated command runtimes apply lifecycle environment to pipes and PTY", async () => {
const fixture = await createFixture("lifecycle-environment", false, "pty", {
lifecycleEnvironment: {
HOME: "/runtime/home",
TMPDIR: "/runtime/tmp",
RUNTIME_VALUE: "runtime",
},
});
await fixture.service.create(fixture.createInput);
const workload = await fixture.service.run({
workspaceId: fixture.workspaceId,
argv: [
processExecPath(),
"-e",
"process.stdout.write(JSON.stringify({home:process.env.HOME,tmp:process.env.TMPDIR,value:process.env.RUNTIME_VALUE,caller:process.env.CALLER_VALUE}))",
],
env: { RUNTIME_VALUE: "caller", CALLER_VALUE: "caller" },
purpose: { kind: "workspace-script", script: "lifecycle-environment" },
});
workload.stdin.end();
await expect(collectText(workload.stdout)).resolves.toBe(
JSON.stringify({
home: "/runtime/home",
tmp: "/runtime/tmp",
value: "runtime",
caller: "caller",
}),
);
await expect(workload.exited).resolves.toEqual({ code: 0, signal: null });
const terminal = await fixture.service.openTerminal({
workspaceId: fixture.workspaceId,
argv: [processExecPath(), "-e", "process.stdout.write(process.env.RUNTIME_VALUE)"],
env: { RUNTIME_VALUE: "caller" },
purpose: { kind: "terminal", terminalId: "lifecycle-environment" },
rows: 24,
cols: 80,
});
let output = "";
terminal.onData((data) => {
output += data;
});
await expect(terminal.exited).resolves.toEqual({ code: 0, signal: null });
expect(output).toContain("runtime");
await fixture.service.destroy(fixture.workspaceId);
}, 15_000);
async function createFixture(
name: string,
withBarrier = false,
@@ -18,7 +18,7 @@ const cleanupRoots: string[] = [];
const posixDescribe = describe.runIf(process.platform !== "win32");
const runtimeContractIds = ["local", "worktree", "fixture"] as const;
const fixtureRuntimeExecutable = fileURLToPath(
new URL("../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url),
new URL("../../../../../runtimes/fixture/src/index.mjs", import.meta.url),
);
afterEach(async () => {
@@ -106,7 +106,6 @@ posixDescribe("setup eligibility", () => {
await expect(
fixture.service.create({
...fixture.createInput,
setupFromPaseoConfig: true,
}),
).resolves.toMatchObject({ runtimeId: "local", cwd: fixture.repo });
@@ -115,7 +114,48 @@ posixDescribe("setup eligibility", () => {
});
});
posixDescribe("setup lifecycle environment", () => {
test("supplies main's lifecycle variables only for setup execution", async () => {
const fixture = await createFixture("worktree");
await fixture.service.create(fixture.createInput);
const runtime = await fixture.service.bind(fixture.workspaceId);
const setup = await runtime.run({
argv: [
"/bin/sh",
"-c",
'printf \'%s\\n\' "$PASEO_SOURCE_CHECKOUT_PATH" "$PASEO_ROOT_PATH" "$PASEO_WORKTREE_PATH" "$PASEO_BRANCH_NAME" "$PASEO_WORKTREE_PORT"',
],
env: {},
purpose: { kind: "setup" },
});
setup.stdin.end();
const output = (await collect(setup.stdout)).trim().split("\n");
await expect(setup.exited).resolves.toEqual({ code: 0, signal: null });
expect(await realpath(output[0]!)).toBe(await realpath(fixture.repo));
expect(output[1]).toBe(output[0]);
expect(await realpath(output[2]!)).toBe(await realpath(fixture.runtimeRoot()));
expect(output[3]).toBeTruthy();
expect(Number(output[4])).toBeGreaterThan(0);
await fixture.service.destroy(fixture.workspaceId);
});
});
posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) => {
test("returns after materialization without waiting for configured setup", async () => {
const fixture = await createFixture(runtimeId);
await writeFile(
path.join(fixture.repo, "paseo.json"),
JSON.stringify({ worktree: { setup: ["while [ ! -f setup-release ]; do sleep 1; done"] } }),
);
const created = await fixture.service.create(fixture.createInput);
expect(created.materializedFreshContent).toBe(runtimeId !== "local");
expect(existsSync(path.join(fixture.runtimeRoot(), "setup-release"))).toBe(false);
await fixture.service.destroy(fixture.workspaceId);
});
test("creates a provider probe by adopting the project root without setup", async () => {
const fixture = await createFixture(runtimeId);
const setupMarker = path.join(fixture.repo, "probe-setup-ran.txt");
@@ -125,7 +165,6 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId)
placement: { kind: "existing" },
purpose: "provider-probe",
setup: [{ argv: ["/bin/sh", "-c", "printf setup > probe-setup-ran.txt"], env: {} }],
setupFromPaseoConfig: false,
});
expect(await realpath(created.cwd)).toBe(await realpath(fixture.repo));
@@ -267,22 +306,27 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId)
workspaceId: fixture.workspaceId,
runtimeId,
cwd: compatibilityCwd,
hostVisiblePath: compatibilityCwd,
...(runtimeId === "fixture" ? {} : { hostVisiblePath: compatibilityCwd }),
materializedFreshContent: runtimeId !== "local",
});
await expect(fixture.service.inspect(fixture.workspaceId)).resolves.toEqual({
status: "ready",
cwd: compatibilityCwd,
hostVisiblePath: compatibilityCwd,
...(runtimeId === "fixture" ? {} : { hostVisiblePath: compatibilityCwd }),
});
await expect(fixture.service.requireHostVisiblePath(fixture.workspaceId)).resolves.toBe(
compatibilityCwd,
);
if (runtimeId === "fixture") {
await expect(fixture.service.requireHostVisiblePath(fixture.workspaceId)).rejects.toThrow(
`Workspace has no host-visible path: ${fixture.workspaceId}`,
);
} else {
await expect(fixture.service.requireHostVisiblePath(fixture.workspaceId)).resolves.toBe(
compatibilityCwd,
);
}
const runtimeRoot = fixture.runtimeRoot();
expect(await readFile(path.join(runtimeRoot, "committed.txt"), "utf8")).toBe("committed\n");
if (runtimeId === "worktree") {
expect(await readFile(path.join(runtimeRoot, "setup-owned.txt"), "utf8")).toBe("setup\n");
}
expect(existsSync(path.join(runtimeRoot, "setup-owned.txt"))).toBe(false);
const child = await fixture.service.run({
workspaceId: fixture.workspaceId,
@@ -369,11 +413,7 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId)
await fixture.service.create(input);
await fixture.service.pause(fixture.workspaceId);
await fixture.service.create(input);
if (runtimeId === "local") {
expect(existsSync(path.join(fixture.runtimeRoot(), setupMarker))).toBe(false);
} else {
expect(await readFile(path.join(fixture.runtimeRoot(), setupMarker), "utf8")).toBe("setup\n");
}
expect(existsSync(path.join(fixture.runtimeRoot(), setupMarker))).toBe(false);
await expect(
fixture.service.run({
workspaceId: fixture.workspaceId,
@@ -43,6 +43,7 @@ import type { ForgeService } from "../services/forge-service.js";
import { areEquivalentPaths } from "../utils/path.js";
import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeDeps,
type CreatePaseoWorktreeFn,
} from "./paseo-worktree-service.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
@@ -375,6 +376,41 @@ function createPaseoWorktreeForTest(options: {
: {}),
workspaceGitService,
workspaceProvisioning,
workspaceRegistry,
workspaceRuntime: {
create: async (runtimeInput) => {
if (runtimeInput.placement.kind !== "resolved-worktree") {
throw new Error("Expected resolved worktree placement");
}
const worktree = await createWorktreePrimitive({
cwd:
runtimeInput.project.source.kind === "host-directory"
? runtimeInput.project.source.path
: input.cwd,
worktreeSlug: runtimeInput.placement.worktreeSlug,
source: runtimeInput.placement.source,
runSetup: false,
paseoHome: options.paseoHome,
});
const record = await workspaceRegistry.get(runtimeInput.workspaceId);
if (record) {
await workspaceRegistry.upsert({
...record,
cwd: worktree.worktreePath,
hostVisiblePath: worktree.worktreePath,
runtime: { runtimeId: "worktree" },
});
}
return {
workspaceId: runtimeInput.workspaceId,
runtimeId: "worktree",
cwd: worktree.worktreePath,
hostVisiblePath: worktree.worktreePath,
materializedFreshContent: true,
};
},
destroy: async () => {},
} as unknown as CreatePaseoWorktreeDeps["workspaceRuntime"],
});
};
}
+81 -41
View File
@@ -26,12 +26,12 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
import { expandTilde } from "../utils/path.js";
import {
getWorktreeSetupCommands,
resolveWorktreeRuntimeEnv,
runWorktreeSetupCommands,
slugify,
validateBranchSlug,
type WorktreeConfig,
type WorktreeSetupExecution,
type WorktreeSetupCommandResult,
WorktreeSetupError,
} from "../utils/worktree.js";
@@ -48,6 +48,7 @@ import {
listPaseoWorktreesCommand,
} from "./worktree/commands.js";
import type { WorkspaceSetupOperation } from "./workspace-setup-runtime.js";
import type { BoundWorkspaceRuntime } from "./workspace-runtime/index.js";
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
@@ -110,7 +111,7 @@ interface CreatePaseoWorktreeInBackgroundDependencies {
getDaemonTcpHost: (() => string | null) | null;
serviceProxyPublicBaseUrl?: string | null;
onScriptsChanged: ((workspaceId: string, workspaceDirectory: string) => void) | null;
runWorkspaceSetup?: (workspaceId: string, signal?: AbortSignal) => Promise<void>;
bindWorkspaceRuntime?: (workspaceId: string) => Promise<BoundWorkspaceRuntime>;
}
interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInBackgroundDependencies {
@@ -651,13 +652,23 @@ export async function createPaseoWorktreeWorkflow(
setupContinuation: {
kind: "agent",
startAfterAgentCreate: ({ agentId }) => {
if (dependencies.runWorkspaceSetup) {
void dependencies.runWorkspaceSetup(workspace.workspaceId).catch((error) => {
dependencies.sessionLogger.error(
{ err: error, workspaceId: workspace.workspaceId, agentId },
"Runtime-owned workspace setup failed",
);
});
if (dependencies.bindWorkspaceRuntime) {
void dependencies
.bindWorkspaceRuntime(workspace.workspaceId)
.then((runtime) =>
runWorktreeSetupCommands({
worktreePath: workspace.cwd,
branchName: workspace.branch ?? "",
cleanupOnFailure: false,
execution: createBoundSetupExecution(runtime),
}),
)
.catch((error) => {
dependencies.sessionLogger.error(
{ err: error, workspaceId: workspace.workspaceId, agentId },
"Runtime-owned workspace setup failed",
);
});
return;
}
void runAsyncWorktreeBootstrap({
@@ -746,41 +757,47 @@ export async function runWorktreeSetupInBackground(
if (!options.shouldBootstrap) {
emitSetupProgress("completed", null);
} else if (dependencies.runWorkspaceSetup) {
setupStarted = true;
await dependencies.runWorkspaceSetup(workspaceId, signal);
} else if (dependencies.bindWorkspaceRuntime) {
const runtime = await dependencies.bindWorkspaceRuntime(workspaceId);
const workspaceCwd = options.workspaceCwd ?? options.worktreePath;
setupResults = await runWorktreeSetupCommands({
worktreePath: workspaceCwd,
branchName: worktree.branchName,
cleanupOnFailure: false,
signal,
execution: createBoundSetupExecution(runtime),
onEvent: (event) => {
if (event.type === "command_started") setupStarted = true;
applyWorktreeSetupProgressEvent(progressAccumulator, event);
emitSetupProgress("running", null);
},
});
emitSetupProgress("completed", null);
} else {
const workspaceCwd = options.workspaceCwd ?? worktree.worktreePath;
const setupCommands = getWorktreeSetupCommands(workspaceCwd);
if (setupCommands.length === 0) {
setupStarted = true;
emitSetupProgress("completed", null);
} else {
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
repoRootPath: options.repoRoot,
});
dependencies.terminalManager?.registerCwdEnv({
cwd: workspaceCwd,
env: runtimeEnv,
});
setupStarted = true;
setupResults = await runWorktreeSetupCommands({
worktreePath: workspaceCwd,
branchName: worktree.branchName,
cleanupOnFailure: false,
repoRootPath: options.repoRoot,
runtimeEnv,
signal,
onEvent: (event) => {
applyWorktreeSetupProgressEvent(progressAccumulator, event);
emitSetupProgress("running", null);
},
});
emitSetupProgress("completed", null);
}
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
repoRootPath: options.repoRoot,
});
dependencies.terminalManager?.registerCwdEnv({
cwd: workspaceCwd,
env: runtimeEnv,
});
setupResults = await runWorktreeSetupCommands({
worktreePath: workspaceCwd,
branchName: worktree.branchName,
cleanupOnFailure: false,
repoRootPath: options.repoRoot,
runtimeEnv,
signal,
onEvent: (event) => {
if (event.type === "command_started") setupStarted = true;
applyWorktreeSetupProgressEvent(progressAccumulator, event);
emitSetupProgress("running", null);
},
});
emitSetupProgress("completed", null);
}
} catch (error) {
if (error instanceof WorktreeSetupError) {
@@ -810,3 +827,26 @@ export async function runWorktreeSetupInBackground(
await dependencies.emitWorkspaceUpdateForWorkspaceId(options.workspaceId);
}
}
function createBoundSetupExecution(runtime: BoundWorkspaceRuntime): WorktreeSetupExecution {
return {
async readFile(path) {
const stat = await runtime.files.stat(path);
if (stat.status === "missing") return null;
if (stat.status === "error") throw new Error(stat.error);
const file = await runtime.files.read(path);
const chunks: Buffer[] = [];
for await (const chunk of file.chunks) chunks.push(Buffer.from(chunk));
return Buffer.concat(chunks);
},
resolveCommand: (command) => runtime.resolveCommand(command),
async run(input) {
return runtime.run({
cwd: ".",
argv: input.argv,
env: input.env,
purpose: { kind: "setup" },
});
},
};
}
@@ -9,8 +9,8 @@ export interface StringCommandShellInvocation {
args: string[];
}
export function createStringCommandShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const sanitized = { ...env };
export function createStringCommandShellEnv<T extends NodeJS.ProcessEnv>(env: T): T {
const sanitized = { ...env } as T;
delete sanitized.BASH_ENV;
return sanitized;
}
@@ -1,5 +1,6 @@
import { type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -31,13 +32,48 @@ function emitSuccessfulClose(child: ChildProcess): void {
function createSpawnChildStub(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
Object.assign(child, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
stdout: new PassThrough(),
stderr: new PassThrough(),
});
queueMicrotask(() => {
(child.stdout as PassThrough).end();
(child.stderr as PassThrough).end();
emitSuccessfulClose(child);
});
queueMicrotask(() => emitSuccessfulClose(child));
return child;
}
async function captureBoundSetupArgv(
resolveCommand: (command: string) => Promise<string | null>,
): Promise<readonly string[]> {
const run = vi.fn(async () => {
const stdout = new PassThrough();
const stderr = new PassThrough();
stdout.end();
stderr.end();
return {
stdin: new PassThrough(),
stdout,
stderr,
exited: Promise.resolve({ code: 0, signal: null }),
kill: vi.fn(),
};
});
const { runWorktreeSetupCommands } = await import("./worktree.js");
await runWorktreeSetupCommands({
worktreePath: "/workspace",
branchName: "runtime-branch",
cleanupOnFailure: false,
execution: {
readFile: async () =>
Buffer.from(JSON.stringify({ worktree: { setup: ["printf runtime-shell"] } })),
resolveCommand,
run,
},
});
return run.mock.calls[0]![0].argv;
}
describe("worktree shell selection", () => {
const originalPlatform = process.platform;
@@ -179,4 +215,62 @@ describe("worktree shell selection", () => {
rmSync(worktreePath, { recursive: true, force: true });
}
});
it("selects the bound runtime shell by capability instead of the host platform", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
const stdout = new PassThrough();
const stderr = new PassThrough();
const run = vi.fn(async () => {
stdout.end("runtime shell\n");
stderr.end();
return {
stdin: new PassThrough(),
stdout,
stderr,
exited: Promise.resolve({ code: 0, signal: null }),
kill: vi.fn(),
};
});
const { runWorktreeSetupCommands } = await import("./worktree.js");
const results = await runWorktreeSetupCommands({
worktreePath: "/workspace",
branchName: "runtime-branch",
cleanupOnFailure: false,
execution: {
readFile: async () =>
Buffer.from(JSON.stringify({ worktree: { setup: ["printf runtime-shell"] } })),
resolveCommand: async (command) => (command === "bash" ? "/runtime/bin/bash" : null),
run,
},
});
expect(run).toHaveBeenCalledWith(
expect.objectContaining({
argv: ["/runtime/bin/bash", "-c", "printf runtime-shell"],
cwd: "/workspace",
}),
);
expect(results).toMatchObject([{ stdout: "runtime shell\n", exitCode: 0 }]);
expect(spawnProcessMock).not.toHaveBeenCalled();
});
it("prefers PowerShell when the runtime exposes Windows and POSIX shells", async () => {
const argv = await captureBoundSetupArgv(async (command) => `/runtime/bin/${command}`);
expect(argv).toEqual([
"/runtime/bin/powershell",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
"printf runtime-shell",
]);
});
it("falls back to cmd when a Windows runtime has no PowerShell", async () => {
const argv = await captureBoundSetupArgv(async (command) =>
command === "cmd.exe" ? "C:\\Windows\\System32\\cmd.exe" : null,
);
expect(argv).toEqual(["C:\\Windows\\System32\\cmd.exe", "/c", "printf runtime-shell"]);
});
});
+208 -67
View File
@@ -1,7 +1,7 @@
import { execFile } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
import { copyFile, rm, stat } from "fs/promises";
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs";
import { copyFile, readFile, rm, stat } from "fs/promises";
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
@@ -10,7 +10,7 @@ import {
buildStringCommandShellInvocation,
createStringCommandShellEnv,
} from "./string-command-shell.js";
import { readPaseoConfigJson, resolvePaseoConfigPath } from "./paseo-config-file.js";
import { resolvePaseoConfigPath } from "./paseo-config-file.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
@@ -37,7 +37,7 @@ import { createExternalProcessEnv } from "../server/paseo-env.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
import { terminateWithTreeKill } from "./tree-kill.js";
import { signalProcessTree } from "./tree-kill.js";
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
@@ -98,6 +98,29 @@ export type WorktreeSetupCommandProgressEvent =
stderr: string;
};
export interface WorktreeSetupExecution {
readFile(path: string): Promise<Uint8Array | null>;
resolveCommand(command: string): Promise<string | null>;
run(input: {
argv: readonly [string, ...string[]];
cwd: string;
env: Readonly<Record<string, string>>;
}): Promise<{
stdin: NodeJS.WritableStream;
stdout: NodeJS.ReadableStream;
stderr: NodeJS.ReadableStream;
exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
kill(signal?: NodeJS.Signals): void;
}>;
}
interface SetupCommandProcess {
stdout: NodeJS.ReadableStream;
stderr: NodeJS.ReadableStream;
exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
signal(signal: NodeJS.Signals): Promise<void>;
}
export interface WorktreeTerminalConfig {
name?: string;
command: string;
@@ -251,14 +274,29 @@ export type ReadPaseoConfigResult =
| { ok: false; configPath: string; error: unknown };
export function readPaseoConfig(repoRoot: string): ReadPaseoConfigResult {
const configPath = resolvePaseoConfigPath(repoRoot);
if (!existsSync(configPath)) {
return { ok: true, config: null };
}
try {
const json = readPaseoConfigJson(repoRoot);
if (json === null) {
return { ok: true, config: null };
}
return { ok: true, config: PaseoConfigSchema.parse(json) };
return { ok: true, config: parsePaseoConfigContents(readFileSync(configPath)) };
} catch (error) {
return { ok: false, configPath: resolvePaseoConfigPath(repoRoot), error };
return { ok: false, configPath, error };
}
}
export function parsePaseoConfigContents(contents: string | Uint8Array): PaseoConfig {
return PaseoConfigSchema.parse(JSON.parse(Buffer.from(contents).toString("utf8")));
}
export function parsePaseoConfigContentsOrThrow(
contents: string | Uint8Array,
configPath: string,
): PaseoConfig {
try {
return parsePaseoConfigContents(contents);
} catch (error) {
throw paseoConfigParseError({ configPath, error });
}
}
@@ -278,7 +316,7 @@ function readPaseoConfigOrThrow(repoRoot: string): PaseoConfig | null {
}
export function getWorktreeSetupCommands(repoRoot: string): string[] {
return readPaseoConfigOrThrow(repoRoot)?.worktree?.setup ?? [];
return [...worktreeSetupCommands(readPaseoConfigOrThrow(repoRoot))];
}
export function getWorktreeTeardownCommands(repoRoot: string): string[] {
@@ -412,7 +450,7 @@ export function processCarriageReturns(text: string): string {
return output.join("");
}
async function execSetupCommand(
async function execLifecycleCommand(
command: string,
options: { cwd: string; env: NodeJS.ProcessEnv },
): Promise<WorktreeSetupCommandResult> {
@@ -452,13 +490,15 @@ async function execSetupCommandStreamed(options: {
total: number;
signal?: AbortSignal;
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
launch(): Promise<SetupCommandProcess>;
}): Promise<WorktreeSetupCommandResult> {
return new Promise((resolvePromise) => {
const startedAt = Date.now();
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
let settled = false;
let termination: Promise<unknown> | null = null;
let termination: Promise<void> | null = null;
let child: SetupCommandProcess | null = null;
const emitOutput = (stream: "stdout" | "stderr", chunk: string) => {
const text = stripAnsi(chunk);
@@ -518,45 +558,46 @@ async function execSetupCommandStreamed(options: {
cwd: options.cwd,
});
const shellInvocation = buildStringCommandShellInvocation({ command: options.command });
const child = spawnProcess(shellInvocation.shell, shellInvocation.args, {
cwd: options.cwd,
env: options.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
const abort = () => {
termination ??= terminateWithTreeKill(child, {
gracefulTimeoutMs: 1000,
forceTimeoutMs: 1000,
});
if (!child) return;
termination ??= terminateSetupProcess(child);
};
if (options.signal?.aborted) {
abort();
} else {
options.signal?.addEventListener("abort", abort, { once: true });
}
child.stdout?.on("data", (chunk: Buffer | string) => {
emitOutput("stdout", chunk.toString());
});
child.stderr?.on("data", (chunk: Buffer | string) => {
emitOutput("stderr", chunk.toString());
});
child.on("error", (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
void finish(null);
});
child.on("close", (code) => {
void finish(typeof code === "number" ? code : null);
});
void options
.launch()
.then(async (launched) => {
child = launched;
options.signal?.addEventListener("abort", abort, { once: true });
if (options.signal?.aborted) abort();
const collect = async (stream: NodeJS.ReadableStream, name: "stdout" | "stderr") => {
for await (const chunk of stream) emitOutput(name, Buffer.from(chunk).toString("utf8"));
};
const [, , exit] = await Promise.all([
collect(launched.stdout, "stdout"),
collect(launched.stderr, "stderr"),
launched.exited,
]);
return finish(exit.code);
})
.catch(async (error) => {
emitOutput("stderr", error instanceof Error ? error.message : String(error));
return finish(null);
});
});
}
async function terminateSetupProcess(child: SetupCommandProcess): Promise<void> {
await child.signal("SIGTERM");
const exited = await Promise.race([
child.exited.then(() => true),
new Promise<false>((resolvePromise) => {
const timer = setTimeout(() => resolvePromise(false), 1_000);
timer.unref();
}),
]);
if (!exited) await child.signal("SIGKILL");
await child.exited;
}
async function getAvailablePort(): Promise<number> {
return new Promise((resolvePromise, reject) => {
const server = net.createServer();
@@ -640,38 +681,47 @@ export async function runWorktreeSetupCommands(options: {
runtimeEnv?: WorktreeRuntimeEnv;
signal?: AbortSignal;
onEvent?: (event: WorktreeSetupCommandProgressEvent) => void;
execution?: WorktreeSetupExecution;
}): Promise<WorktreeSetupCommandResult[]> {
// Read paseo.json from the worktree (it will have the same content as the source repo)
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
const setupCommands = await readSetupCommands(options.worktreePath, options.execution);
if (setupCommands.length === 0) {
return [];
}
const runtimeEnv =
options.runtimeEnv ??
(await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
branchName: options.branchName,
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
}));
const setupEnv = createStringCommandShellEnv(createExternalProcessEnv(process.env, runtimeEnv));
(options.execution
? {}
: await resolveWorktreeRuntimeEnv({
worktreePath: options.worktreePath,
branchName: options.branchName,
...(options.repoRootPath ? { repoRootPath: options.repoRootPath } : {}),
}));
const setupEnv = options.execution
? {}
: createStringCommandShellEnv(createExternalProcessEnv(process.env, runtimeEnv));
const results: WorktreeSetupCommandResult[] = [];
for (const [index, cmd] of setupCommands.entries()) {
const result = options.onEvent
? await execSetupCommandStreamed({
const commandInput = {
command: cmd,
cwd: options.worktreePath,
env: setupEnv,
index: index + 1,
total: setupCommands.length,
signal: options.signal,
onEvent: options.onEvent,
};
const result = await execSetupCommandStreamed({
...commandInput,
launch: () =>
launchSetupCommand({
command: cmd,
cwd: options.worktreePath,
env: setupEnv,
index: index + 1,
total: setupCommands.length,
signal: options.signal,
onEvent: options.onEvent,
})
: await execSetupCommand(cmd, {
cwd: options.worktreePath,
env: setupEnv,
});
execution: options.execution,
}),
});
results.push(result);
if (result.exitCode !== 0) {
@@ -695,6 +745,97 @@ export async function runWorktreeSetupCommands(options: {
return results;
}
async function launchSetupCommand(options: {
command: string;
cwd: string;
env: NodeJS.ProcessEnv;
execution?: WorktreeSetupExecution;
}): Promise<SetupCommandProcess> {
const env = Object.fromEntries(
Object.entries(options.env).filter(
(entry): entry is [string, string] => entry[1] !== undefined,
),
);
if (options.execution) {
const shell = await resolveRuntimeSetupShell(options.execution, options.command);
const child = await options.execution.run({ argv: shell, cwd: options.cwd, env });
child.stdin.end();
return {
stdout: child.stdout,
stderr: child.stderr,
exited: child.exited,
async signal(signal) {
child.kill(signal);
},
};
}
const shell = buildStringCommandShellInvocation({ command: options.command });
const child = spawnProcess(shell.shell, shell.args, {
cwd: options.cwd,
env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolveExit) => {
child.once("error", () => resolveExit({ code: null, signal: null }));
child.once("close", (code, signal) => resolveExit({ code, signal }));
},
);
return {
stdout: child.stdout!,
stderr: child.stderr!,
exited,
signal: (signal) => signalProcessTree(child, signal),
};
}
async function resolveRuntimeSetupShell(
execution: WorktreeSetupExecution,
command: string,
): Promise<readonly [string, ...string[]]> {
const cmd = await execution.resolveCommand("cmd.exe");
if (cmd) {
const powershell =
(await execution.resolveCommand("powershell")) ??
(await execution.resolveCommand("powershell.exe"));
const invocation = buildStringCommandShellInvocation({
command,
platform: "win32",
...(powershell ? {} : { windowsShell: "cmd" as const }),
});
return [powershell ?? cmd, ...invocation.args];
}
const bash = await execution.resolveCommand("bash");
if (bash) {
const invocation = buildStringCommandShellInvocation({ command, platform: "linux" });
return [bash, ...invocation.args];
}
throw new Error("Setup runtime has no supported command shell");
}
async function readSetupCommands(
worktreePath: string,
execution?: WorktreeSetupExecution,
): Promise<readonly string[]> {
const contents = execution
? await execution.readFile("paseo.json")
: await readFile(resolvePaseoConfigPath(worktreePath)).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!contents) return [];
return worktreeSetupCommands(
parsePaseoConfigContentsOrThrow(contents, resolvePaseoConfigPath(worktreePath)),
);
}
function worktreeSetupCommands(config: PaseoConfig | null): readonly string[] {
return config?.worktree?.setup ?? [];
}
async function resolveBranchNameForWorktreePath(worktreePath: string): Promise<string> {
try {
const { stdout } = await runGitCommand(["branch", "--show-current"], {
@@ -783,7 +924,7 @@ export async function runWorktreeTeardownCommands(options: {
const results: WorktreeTeardownCommandResult[] = [];
for (const cmd of teardownCommands) {
const result = await execSetupCommand(cmd, {
const result = await execLifecycleCommand(cmd, {
cwd: teardownCwd,
env: teardownEnv,
});
+12
View File
@@ -0,0 +1,12 @@
# `@getpaseo/workspace-helper`
This is the official workspace filesystem helper for Paseo command runtimes. It owns the
executable, protocol, typed client binding, and confinement rules.
Runtime authors depend on the same release as `@getpaseo/workspace-runtime-contract` and bundle
the `paseo-workspace-helper` bin. Do not implement the helper protocol. The command-runtime
contract and helper protocol have independent version fields; a Paseo release pins compatible
package versions and verifies both describe responses before using a runtime.
The helper receives workspace authority only from its process cwd. All API paths are relative.
Absolute paths, traversal, and symlink escapes are rejected.
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@getpaseo/workspace-helper",
"version": "0.3.1",
"description": "Official confined workspace filesystem helper and typed binding for Paseo runtimes",
"bin": {
"paseo-workspace-helper": "./dist/executable.mjs"
},
"files": [
"dist",
"README.md"
],
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "tsc -p tsconfig.json --incremental false && node -e \"const fs=require('node:fs');fs.copyFileSync('src/executable.mjs','dist/executable.mjs');fs.chmodSync('dist/executable.mjs',0o755)\"",
"build:clean": "npm run clean && npm run build",
"test": "npm run build && vitest run test/workspace-helper.posix.test.ts --bail=1",
"test:standalone": "node --test test/standalone.test.mjs",
"typecheck": "tsgo -p tsconfig.json --noEmit"
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.9.3",
"vitest": "^4.1.6"
}
}
@@ -1,7 +1,7 @@
import type { Readable, Writable } from "node:stream";
import type { WorkspaceFiles } from "../../index.js";
import { createClient } from "../client.js";
import type { WorkspaceFiles } from "./files.js";
import { createClient } from "./client.js";
export interface WorkspaceHelperProcess {
stdin: Writable;
@@ -18,7 +18,6 @@ export interface WorkspaceFilesOwner {
close(): Promise<void>;
}
/** @package Parent integration SPI. Normal callers consume WorkspaceFiles from the public module. */
export function bindWorkspaceHelper(options: {
command: readonly [string, ...string[]];
launch(argv: readonly [string, ...string[]]): Promise<WorkspaceHelperProcess>;
@@ -1,5 +1,5 @@
import type { WorkspaceFileContent, WorkspaceFileRead, WorkspaceWatchEvent } from "../index.js";
import type { WorkspaceFilesOwner, WorkspaceHelperProcess } from "./integration/index.js";
import type { WorkspaceFileContent, WorkspaceFileRead, WorkspaceWatchEvent } from "./files.js";
import type { WorkspaceFilesOwner, WorkspaceHelperProcess } from "./binding.js";
import {
describeSchema,
directorySchema,
@@ -1,4 +1,4 @@
#!/usr/local/bin/node
#!/usr/bin/env node
import { randomUUID } from "node:crypto";
import { constants, watch } from "node:fs";
import { access, open, realpath, readdir, rename, stat, unlink } from "node:fs/promises";
+3
View File
@@ -0,0 +1,3 @@
export * from "./binding.js";
export * from "./files.js";
export { WORKSPACE_HELPER_PROTOCOL_VERSION, workspaceHelperExecutable } from "./location.js";
@@ -0,0 +1,6 @@
import { fileURLToPath } from "node:url";
export const WORKSPACE_HELPER_PROTOCOL_VERSION = 1 as const;
export const workspaceHelperExecutable = fileURLToPath(
new URL("./executable.mjs", import.meta.url),
);
@@ -54,6 +54,7 @@ function print(value) {
function handleWatchCommand(command, finish) {
if (command.type === "subscribe" && mode !== "no-subscribed") {
print({ type: "subscribed", subscriptionId: command.id });
if (mode === "overflow") print({ type: "overflow", subscriptionId: command.id });
return;
}
if (command.type === "unsubscribe" && mode !== "no-unsubscribed") {
@@ -0,0 +1,90 @@
import { execFile } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { promisify } from "node:util";
const run = promisify(execFile);
const packageRoot = path.resolve(import.meta.dirname, "..");
test("packed helper works from a fresh project without monorepo source fallback", async (t) => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-helper-standalone-"));
t.after(() => rm(root, { recursive: true, force: true }));
const packDirectory = path.join(root, "packs");
const project = path.join(root, "project");
await Promise.all([mkdir(packDirectory), mkdir(project)]);
await run("npm", ["run", "build"], { cwd: packageRoot });
const packed = JSON.parse(
(
await run("npm", ["pack", "--json", "--pack-destination", packDirectory], {
cwd: packageRoot,
})
).stdout,
)[0].filename;
await writeFile(
path.join(project, "package.json"),
JSON.stringify({
type: "module",
dependencies: { "@getpaseo/workspace-helper": `file:${path.join(packDirectory, packed)}` },
}),
);
await run("npm", ["install", "--ignore-scripts"], { cwd: project });
await writeFile(path.join(project, "notes.txt"), "before\n");
const imported = JSON.parse(
(
await run(
process.execPath,
[
"--input-type=module",
"-e",
"import('@getpaseo/workspace-helper').then(m=>console.log(JSON.stringify({version:m.WORKSPACE_HELPER_PROTOCOL_VERSION,bin:m.workspaceHelperExecutable})))",
],
{ cwd: project },
)
).stdout,
);
assert.equal(imported.version, 1);
assert.match(imported.bin, /node_modules\/@getpaseo\/workspace-helper\/dist\/executable\.mjs$/u);
const bin = path.join(project, "node_modules/.bin/paseo-workspace-helper");
const described = JSON.parse((await run(bin, ["describe"], { cwd: project })).stdout);
assert.deepEqual(described, {
protocolVersion: 1,
version: 1,
capabilities: ["files", "watch", "resolve-command"],
});
assert.equal(
JSON.parse((await run(bin, ["fs-stat", "--path", "notes.txt"], { cwd: project })).stdout)
.status,
"ready",
);
const watch = await import("node:child_process").then(({ spawn }) =>
spawn(bin, ["watch"], { cwd: project, stdio: ["pipe", "pipe", "pipe"] }),
);
const output = [];
watch.stdout.setEncoding("utf8");
watch.stdout.on("data", (chunk) => output.push(chunk));
watch.stdin.write(
`${JSON.stringify({ protocolVersion: 1, type: "subscribe", id: "proof", paths: ["notes.txt"], recursive: false, ignoredPaths: [] })}\n`,
);
await until(() => output.join("").includes('"type":"subscribed"'));
await writeFile(path.join(project, "notes.txt"), "after\n");
await until(() => output.join("").includes('"type":"changed"'));
watch.stdin.end(`${JSON.stringify({ protocolVersion: 1, type: "close" })}\n`);
await new Promise((resolve, reject) => {
watch.once("error", reject);
watch.once("exit", resolve);
});
assert.equal(await readFile(path.join(project, "notes.txt"), "utf8"), "after\n");
});
async function until(condition) {
const deadline = Date.now() + 5_000;
while (!condition()) {
if (Date.now() > deadline) throw new Error("Timed out waiting for helper watch event");
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
@@ -6,15 +6,15 @@ import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
import type { WorkspaceWatchEvent } from "./index.js";
import { bindWorkspaceHelper, type WorkspaceHelperProcess } from "./internal/integration/index.js";
import type { WorkspaceWatchEvent } from "../src/files.js";
import { bindWorkspaceHelper, type WorkspaceHelperProcess } from "../src/binding.js";
const posixDescribe = describe.runIf(process.platform !== "win32");
const cleanupRoots: string[] = [];
const adversarialHelper = fileURLToPath(
new URL("./fixtures/adversarial-helper.mjs", import.meta.url),
);
const workspaceHelper = fileURLToPath(new URL("./executable.mjs", import.meta.url));
const workspaceHelper = fileURLToPath(new URL("../src/executable.mjs", import.meta.url));
afterEach(async () => {
await Promise.all(
@@ -41,6 +41,52 @@ async function collect(chunks: AsyncIterable<Uint8Array>): Promise<Buffer> {
}
posixDescribe("workspace-helper public capability", () => {
test("executable describe reports the exact versioned capability contract", async () => {
const child = spawn(process.execPath, [workspaceHelper, "describe"], {
stdio: ["ignore", "pipe", "pipe"],
});
const [stdout, stderr, exit] = await Promise.all([
collect(child.stdout),
collect(child.stderr),
childProcess(child).exited,
]);
expect(exit, stderr.toString()).toEqual({ code: 0, signal: null });
expect(JSON.parse(stdout.toString())).toEqual({
protocolVersion: 1,
version: 1,
capabilities: ["files", "watch", "resolve-command"],
});
});
test("watch framing accepts split frames and rejects malformed input", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-framing-"));
cleanupRoots.push(parent);
const child = spawn(process.execPath, [workspaceHelper, "watch"], {
cwd: parent,
stdio: ["pipe", "pipe", "pipe"],
});
const stdout = collect(child.stdout);
const stderr = collect(child.stderr);
child.stdin.write('{"protocolVersion":1,"type":"subscribe","id":"split","paths":["."],');
child.stdin.end('"recursive":false,"ignoredPaths":[]}\nnot-json\n');
await expect(childProcess(child).exited).resolves.toEqual({ code: 1, signal: null });
expect((await stdout).toString()).toContain('"type":"ready"');
expect((await stdout).toString()).toContain('"type":"subscribed"');
expect((await stderr).toString()).toMatch(/JSON|Unexpected token/);
});
test("overflow frames propagate through the typed binding", async () => {
const recorded = await recordedClient("overflow");
try {
const event = new Promise<WorkspaceWatchEvent>((resolve) => {
void recorded.client.files.subscribe({ paths: ["notes.txt"] }, resolve);
});
await expect(event).resolves.toEqual({ type: "overflow" });
} finally {
await recorded.cleanup();
}
});
test("client sends only workspace-relative helper paths", async () => {
const { client } = await fixture();
expect(() => client.files.stat("/tmp/outside")).toThrow(
@@ -1,10 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"incremental": false,
"noEmit": false,
"types": ["node"]
},
@@ -2,6 +2,11 @@
Implement this contract to add a trusted workspace runtime without importing Paseo server code. Use the schemas and types from `@getpaseo/workspace-runtime-contract`; the tested byte examples are in [`examples/v1.json`](examples/v1.json).
The command-runtime protocol and workspace-helper protocol have independent version fields. A
Paseo release pins compatible versions of this package and `@getpaseo/workspace-helper`, then
verifies both `describe` responses. Runtime packages depend on both exact compatible versions and
bundle the official helper bin. Runtime authors do not reimplement its protocol.
## Commands
Your configured command receives one operation:
@@ -20,9 +25,9 @@ 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 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.
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: "discovery"` field marks a short-lived environment-discovery workspace without naming the higher-level consumer. 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.
`project.source` is either a directory visible to the runtime wrapper 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` never overrides it. A `discovery` 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.
A runtime owns the meaning of its `options`. Keep runtime-specific mount, container, VM, or supervisor authority out of lifecycle state and placement.
@@ -40,7 +45,7 @@ The authoritative fd 4 `exit` preserves the workload code or terminating signal;
`workspaceId` is the reconciliation and ownership key. `create`, `pause`, `resume`, and `destroy` are idempotent. `inspect` is authoritative after either process restarts. `spawn` fails unless inspection is `ready`. `pause` preserves workspace state; `destroy` removes only resources owned by that workspace. `reconcile` may remove orphaned owned resources and must reject ownership mismatches.
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.
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.
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`.
@@ -48,4 +53,4 @@ Every runtime must provide the compatible `paseo-workspace-helper` executable on
Version 1 targets macOS/Linux hosts and POSIX runtime environments. Unknown v1 fields are rejected; future fields require a new protocol version. A semantic change to framing, fd ownership, lifecycle, or cleanup also increments the protocol version. Paseo fails closed on a version mismatch; it never falls back to host execution.
The private `@getpaseo/fixture-workspace-runtime` package is the executable contract fixture. It records the validated create input, can copy a host-directory source into an owned root, exposes deterministic failure and placement options, and runs workloads through both pipe and PTY framing. It is test infrastructure, not a production runtime or a second schema owner; all validation comes from this package's exported schemas.
The private `@getpaseo/fixture-workspace-runtime` package is the executable contract fixture. It records the validated create input, can copy a directory source into owned storage, exposes deterministic failure and placement options, and runs workloads through both pipe and PTY framing. It is test infrastructure, not a production runtime or a second schema owner; all validation comes from this package's exported schemas.
@@ -19,7 +19,7 @@
}
},
"placement": { "kind": "existing" },
"purpose": "provider-probe"
"purpose": "discovery"
},
"options": {}
},
@@ -40,7 +40,7 @@
"argv": ["/bin/sh", "-c", "printf hello"],
"cwd": "packages/app",
"env": { "PATH": "/usr/bin:/bin" },
"purpose": { "kind": "workspace-script", "script": "verify" },
"purpose": { "kind": "workspace-script" },
"options": {},
"execId": "exec-pipe-01",
"stdio": { "kind": "pipes" }
@@ -52,7 +52,7 @@
"argv": ["/bin/sh", "-c", "printf hello"],
"cwd": "packages/app",
"env": { "PATH": "/usr/bin:/bin" },
"purpose": { "kind": "workspace-script", "script": "verify" },
"purpose": { "kind": "workspace-script" },
"options": {},
"execId": "exec-pty-01",
"stdio": { "kind": "pty", "rows": 24, "cols": 80, "term": "xterm-256color" }
@@ -85,10 +85,10 @@
},
"bytes": {
"describeResponse": "{\"protocolVersion\":1,\"modes\":[\"pipes\",\"pty\"],\"reconcile\":true}\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",
"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\":\"discovery\"},\"options\":{}}\n",
"lifecycleResponse": "{\"type\":\"state\",\"protocolVersion\":1,\"state\":{\"workspaceId\":\"workspace-01\",\"lifecycle\":\"ready\"},\"placement\":{\"cwd\":\"/workspace\"},\"materializedFreshContent\":true}\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",
"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\"},\"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\"},\"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",
@@ -8,7 +8,7 @@ const OptionsSchema = z.record(z.string(), z.json());
const CommandSchema = z.array(z.string()).min(1);
export const CommandRuntimeProjectSourceSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("host-directory"), path: z.string() }).strict(),
z.object({ kind: z.literal("directory"), path: z.string() }).strict(),
z
.object({
kind: z.literal("git"),
@@ -83,7 +83,7 @@ export const CommandRuntimeCreateInputSchema = z
.object({ projectId: z.string(), source: CommandRuntimeProjectSourceSchema })
.strict(),
placement: CommandRuntimePlacementIntentSchema,
purpose: z.literal("provider-probe").optional(),
purpose: z.literal("discovery").optional(),
markFirstAgentBranchAutoName: z.boolean().optional(),
seedPaseoConfigFrom: z.string().optional(),
})
@@ -110,7 +110,6 @@ export const CommandRuntimeStateSchema = z
export const CommandRuntimePlacementSchema = z
.object({
cwd: z.string(),
hostVisiblePath: z.string().optional(),
})
.strict();
@@ -162,12 +161,12 @@ export const CommandRuntimeDescribeResponseSchema = z
.strict();
export const CommandRuntimeProcessPurposeSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("agent"), agentId: z.string(), provider: z.string() }).strict(),
z.object({ kind: z.literal("terminal"), terminalId: z.string() }).strict(),
z.object({ kind: z.literal("agent") }).strict(),
z.object({ kind: z.literal("terminal") }).strict(),
z.object({ kind: z.literal("git") }).strict(),
z.object({ kind: z.literal("provider-probe"), provider: z.string() }).strict(),
z.object({ kind: z.literal("discovery") }).strict(),
z.object({ kind: z.literal("workspace-helper") }).strict(),
z.object({ kind: z.literal("workspace-script"), script: z.string() }).strict(),
z.object({ kind: z.literal("workspace-script") }).strict(),
z.object({ kind: z.literal("setup") }).strict(),
z.object({ kind: z.literal("archive") }).strict(),
]);
@@ -5,6 +5,11 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import test from "node:test";
import assert from "node:assert/strict";
test("the public contract contains no host or provider authority fields", async () => {
const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /host-directory|hostVisiblePath|provider-probe|provider:/u);
});
import { promisify } from "node:util";
import {
@@ -54,7 +59,7 @@ test("documented lifecycle, fd3, control, and fd4 examples are exact newline-ter
});
test("lifecycle uses one exact stdin request and one exact stdout response", async () => {
assert.equal(examples.lifecycleRequest.input.purpose, "provider-probe");
assert.equal(examples.lifecycleRequest.input.purpose, "discovery");
assert.deepEqual(examples.lifecycleResponse.state, {
workspaceId: "workspace-01",
lifecycle: "ready",
@@ -425,25 +430,10 @@ test("every public v1 object rejects unknown authority at every nesting level",
{ protocolVersion: 1, type: "ok" },
[],
],
[
"agent purpose",
CommandRuntimeProcessPurposeSchema,
{ kind: "agent", agentId: "a", provider: "p" },
[],
],
[
"terminal purpose",
CommandRuntimeProcessPurposeSchema,
{ kind: "terminal", terminalId: "t" },
[],
],
["agent purpose", CommandRuntimeProcessPurposeSchema, { kind: "agent" }, []],
["terminal purpose", CommandRuntimeProcessPurposeSchema, { kind: "terminal" }, []],
["Git purpose", CommandRuntimeProcessPurposeSchema, { kind: "git" }, []],
[
"provider purpose",
CommandRuntimeProcessPurposeSchema,
{ kind: "provider-probe", provider: "p" },
[],
],
["discovery purpose", CommandRuntimeProcessPurposeSchema, { kind: "discovery" }, []],
["helper purpose", CommandRuntimeProcessPurposeSchema, { kind: "workspace-helper" }, []],
["script purpose", CommandRuntimeProcessPurposeSchema, examples.pipeSpawnControl.purpose, []],
["setup purpose", CommandRuntimeProcessPurposeSchema, { kind: "setup" }, []],
+7 -14
View File
@@ -71,30 +71,23 @@ Relative paths are resolved against `PASEO_HOME`. Existing worktrees remain wher
## Workspace runtimes
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:
```bash
docker build -f packages/docker-workspace-runtime/Dockerfile -t paseo-workspace-runtime:local .
```
Then replace the distribution registration's opaque image option in daemon configuration:
New Workspace always offers Local and Worktree. Other runtimes appear only when you register an
external command runtime in daemon configuration:
```json
{
"workspaceRuntimes": {
"docker": {
"isolated": {
"type": "command",
"label": "Docker",
"command": ["@getpaseo/docker-workspace-runtime"],
"options": { "image": "paseo-workspace-runtime:local", "bindMounts": [] }
"label": "Isolated",
"command": ["/absolute/path/to/runtime-executable"],
"options": {}
}
}
}
```
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).
Every registered runtime uses `type: "command"`. `command` is an argv array, not a shell string. Its first value may be a 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. Remove an entry to unregister 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
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@getpaseo/fixture-workspace-runtime",
"version": "0.3.1",
"private": true,
"description": "External command-runtime contract fixture",
"bin": {
"paseo-fixture-workspace-runtime": "./src/index.mjs"
},
"files": [
"src",
"dist"
],
"type": "module",
"scripts": {
"build": "npm --prefix ../.. run build:workspace-runtime-contract && npm --prefix ../.. run build:workspace-helper && node --check src/index.mjs && node -e \"import('@getpaseo/workspace-helper').then(({workspaceHelperExecutable})=>{const fs=require('node:fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync(workspaceHelperExecutable,'dist/paseo-workspace-helper');fs.chmodSync('dist/paseo-workspace-helper',0o755)})\"",
"typecheck": "node --check src/index.mjs"
},
"dependencies": {
"@getpaseo/workspace-helper": "0.3.1",
"@getpaseo/workspace-runtime-contract": "0.3.1",
"node-pty": "1.2.0-beta.15"
}
}
@@ -9,6 +9,8 @@ import { fileURLToPath } from "node:url";
import { Socket } from "node:net";
import { createInterface } from "node:readline";
import * as pty from "node-pty";
const workspaceHelper = fileURLToPath(new URL("../dist/paseo-workspace-helper", import.meta.url));
import {
COMMAND_RUNTIME_PROTOCOL_VERSION,
CommandRuntimeControlSchema,
@@ -146,8 +148,8 @@ async function create(id, request) {
displayCwd: request.options.preserveSourceDisplayCwd
? sourceRoot
: (request.options.displayCwd ?? root),
hostVisiblePath: request.options.exposeHostVisiblePath === false ? undefined : root,
lifecycle: "ready",
lifecycleEnvironment: request.options.lifecycleEnvironment,
ownedRoot,
createInput: request.input,
};
@@ -171,14 +173,15 @@ async function inspect(id, options) {
}
function publicState(state) {
return { workspaceId: state.workspaceId, lifecycle: state.lifecycle };
return {
workspaceId: state.workspaceId,
lifecycle: state.lifecycle,
...(state.lifecycleEnvironment ? { lifecycleEnvironment: state.lifecycleEnvironment } : {}),
};
}
function publicPlacement(state) {
return {
cwd: state.displayCwd,
...(state.hostVisiblePath ? { hostVisiblePath: state.hostVisiblePath } : {}),
};
return { cwd: state.displayCwd };
}
async function setLifecycle(id, options, lifecycle) {
@@ -245,7 +248,7 @@ async function execute(id) {
if (
envelope.options.crashPipeWrapper &&
envelope.purpose.kind === "workspace-script" &&
envelope.purpose.script === "wrapper-crash"
(await consumePipeWrapperCrash(id, envelope.options))
) {
process.exit(47);
}
@@ -286,13 +289,7 @@ function resolveFixtureCommand(envelope) {
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),
];
return [process.execPath, workspaceHelper, ...envelope.argv.slice(1)];
}
async function executePty(id, envelope, controls, controlStream) {
@@ -529,6 +526,16 @@ async function readState(id, options) {
}
}
async function consumePipeWrapperCrash(id, options) {
const state = await readState(id, options);
if (!state || state.pipeWrapperCrashConsumed) return false;
await writeFile(
stateFile(id, options),
JSON.stringify({ ...state, pipeWrapperCrashConsumed: true }),
);
return true;
}
function stateFile(id, options) {
return path.join(options.stateDirectory, `${key(id)}.json`);
}
+337 -94
View File
@@ -7,7 +7,13 @@ import { fileURLToPath } from "node:url";
import ts from "typescript";
const SERVER_ROOT = "packages/server/src/server";
const FIXTURE_ROOT = "packages/fixture-workspace-runtime";
const CORE_SOURCE_ROOTS = [
"packages/server/src",
"packages/cli/src",
"packages/desktop/src",
"packages/app/src",
];
const IN_REPO_RUNTIME_ROOTS = ["runtimes/fixture"];
const migratedOwners = [
"/session/files/",
"/session/git-mutation/",
@@ -17,11 +23,22 @@ const migratedOwners = [
"/session/workspace-scripts/",
];
const hostModules = new Set(["child_process", "fs", "fs/promises", "module", "which"]);
const auditedNonliteralServerLoads = new Set([
"packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts|resolveRuntimeCommand|moduleRequire|resolve",
"packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts|resolveSherpaLoaderEnv|require|resolve",
"packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts|loadWithRequire|requireFn|load",
]);
export async function findWorkspaceBoundaryViolations(repoRoot) {
const files = [
...(await collectSources(path.join(repoRoot, SERVER_ROOT))),
...(await collectSources(path.join(repoRoot, FIXTURE_ROOT))),
...(
await Promise.all(CORE_SOURCE_ROOTS.map((root) => collectSources(path.join(repoRoot, root))))
).flat(),
...(
await Promise.all(
IN_REPO_RUNTIME_ROOTS.map((root) => collectSources(path.join(repoRoot, root))),
)
).flat(),
];
const modules = await Promise.all(
files.map(async (filename) => {
@@ -35,33 +52,15 @@ export async function findWorkspaceBoundaryViolations(repoRoot) {
.map(({ filename }) => toRepoPath(repoRoot, filename)),
);
const violations = [];
for (const { filename, sourceFile } of modules) {
const importer = toRepoPath(repoRoot, filename);
const external = importer.startsWith(`${FIXTURE_ROOT}/`);
const governed =
external ||
(!isTestFile(importer) && (ownsMigratedSurface(importer) || publicOwners.has(importer)));
for (const specifier of staticSpecifiers(sourceFile)) {
if (!isTestFile(importer) && isForbiddenInternal(importer, specifier)) {
violations.push(violation(importer, specifier, "module-entrypoint"));
}
}
for (const load of moduleLoads(sourceFile)) {
if (load.specifier === null) {
if (governed) violations.push(violation(importer, "<computed>", "nonliteral-module-load"));
continue;
}
const specifier = load.specifier;
if (isForbiddenInternal(importer, specifier)) {
violations.push(violation(importer, specifier, "module-entrypoint"));
}
if (governed && !external && isHostCapability(specifier)) {
violations.push(violation(importer, specifier, "workspace-host-access"));
}
if (external && isExternalEscape(importer, specifier)) {
violations.push(violation(importer, specifier, "external-runtime-contract"));
}
}
for (const runtimeRoot of IN_REPO_RUNTIME_ROOTS) {
const ownedModules = modules.filter(({ filename }) =>
toRepoPath(repoRoot, filename).startsWith(`${runtimeRoot}/`),
);
const duplicateHelper = duplicateWorkspaceHelperViolation(repoRoot, runtimeRoot, ownedModules);
if (duplicateHelper) violations.push(duplicateHelper);
}
for (const module of modules) {
violations.push(...moduleViolations(repoRoot, module, publicOwners));
}
return violations.sort((left, right) =>
`${left.file}:${left.import}:${left.rule}`.localeCompare(
@@ -70,6 +69,68 @@ export async function findWorkspaceBoundaryViolations(repoRoot) {
);
}
function moduleViolations(repoRoot, { filename, sourceFile }, publicOwners) {
const importer = toRepoPath(repoRoot, filename);
const external = IN_REPO_RUNTIME_ROOTS.some((root) => importer.startsWith(`${root}/`));
const testFile = isTestFile(importer);
const serverProduction = importer.startsWith(`${SERVER_ROOT}/`) && !testFile;
const governed =
external || (!testFile && (ownsMigratedSurface(importer) || publicOwners.has(importer)));
const violations = [];
for (const specifier of staticSpecifiers(sourceFile)) {
if (isExternalRuntimeAccess(importer, specifier)) {
violations.push(violation(importer, specifier, "server-runtime-access"));
}
if (!testFile && isForbiddenInternal(importer, specifier)) {
violations.push(violation(importer, specifier, "module-entrypoint"));
}
if (external && !testFile && isExternalEscape(importer, specifier)) {
violations.push(violation(importer, specifier, "external-runtime-contract"));
}
}
for (const load of moduleLoads(sourceFile)) {
violations.push(...loadViolations({ importer, external, governed, serverProduction, load }));
}
return violations;
}
function loadViolations({ importer, external, governed, serverProduction, load }) {
if (load.specifier === null) {
const audited = isAuditedNonliteralServerLoad(importer, load);
return external || (serverProduction && !audited)
? [violation(importer, "<computed>", "nonliteral-module-load")]
: [];
}
const violations = [];
if (isExternalRuntimeAccess(importer, load.specifier)) {
violations.push(violation(importer, load.specifier, "server-runtime-access"));
}
if (isForbiddenInternal(importer, load.specifier)) {
violations.push(violation(importer, load.specifier, "module-entrypoint"));
}
if (governed && !external && isHostCapability(load.specifier)) {
violations.push(violation(importer, load.specifier, "workspace-host-access"));
}
if (external && isExternalEscape(importer, load.specifier)) {
violations.push(violation(importer, load.specifier, "external-runtime-contract"));
}
return violations;
}
function implementsWorkspaceHelper(source) {
return ["fs-stat", "fs-list", "fs-read", "fs-write"].every((command) => source.includes(command));
}
function duplicateWorkspaceHelperViolation(repoRoot, runtimeRoot, modules) {
const combinedSource = modules.map(({ sourceFile }) => sourceFile.text).join("\n");
if (!implementsWorkspaceHelper(combinedSource)) return null;
const singleFileOwner = modules.find(({ sourceFile }) =>
implementsWorkspaceHelper(sourceFile.text),
);
const owner = singleFileOwner ? toRepoPath(repoRoot, singleFileOwner.filename) : runtimeRoot;
return violation(owner, "workspace-helper protocol", "duplicate-workspace-helper");
}
function parseSource(filename, source) {
return ts.createSourceFile(
filename,
@@ -96,61 +157,43 @@ function staticSpecifiers(sourceFile) {
function moduleLoads(sourceFile) {
const loads = [];
const constants = constantBindings(sourceFile);
const loaderAliases = moduleLoaderAliases(sourceFile);
visit(sourceFile);
return loads;
function visit(node) {
if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && loaderAliases.has(node.expression.text)))
) {
if (ts.isCallExpression(node)) {
const load = moduleLoad(node.expression, loaderAliases);
if (!load) {
ts.forEachChild(node, visit);
return;
}
const argument = node.arguments.length === 1 ? node.arguments[0] : undefined;
loads.push({ specifier: argument ? constantString(argument, constants) : null });
loads.push({
...load,
owner: enclosingFunctionName(node),
specifier: argument ? literalModuleSpecifier(argument) : null,
});
}
ts.forEachChild(node, visit);
}
}
function constantBindings(sourceFile) {
const constants = new Map();
let changed = true;
while (changed) {
changed = false;
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue;
if (!(statement.declarationList.flags & ts.NodeFlags.Const)) continue;
for (const declaration of statement.declarationList.declarations) {
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
const value = constantString(declaration.initializer, constants);
if (value === null || constants.get(declaration.name.text) === value) continue;
constants.set(declaration.name.text, value);
changed = true;
}
}
}
return constants;
}
function moduleLoaderAliases(sourceFile) {
const aliases = new Set(["require"]);
const declarations = variableDeclarations(sourceFile);
const moduleObjects = moduleObjectAliases(sourceFile, declarations);
const factories = createRequireAliases(sourceFile, declarations, moduleObjects);
collectNodeRequireParameters(sourceFile, aliases);
let changed = true;
while (changed) {
changed = false;
for (const statement of sourceFile.statements) {
if (!ts.isVariableStatement(statement)) continue;
for (const declaration of statement.declarationList.declarations) {
if (
!ts.isIdentifier(declaration.name) ||
!declaration.initializer ||
!ts.isIdentifier(declaration.initializer) ||
!aliases.has(declaration.initializer.text) ||
aliases.has(declaration.name.text)
) {
continue;
}
for (const declaration of declarations) {
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
const aliasesLoader =
ts.isIdentifier(declaration.initializer) && aliases.has(declaration.initializer.text);
const createsLoader = isCreateRequireCall(declaration.initializer, factories, moduleObjects);
if ((aliasesLoader || createsLoader) && !aliases.has(declaration.name.text)) {
aliases.add(declaration.name.text);
changed = true;
}
@@ -159,34 +202,219 @@ function moduleLoaderAliases(sourceFile) {
return aliases;
}
function constantString(expression, constants) {
function isCreateRequireCall(expression, factories, moduleObjects) {
if (!ts.isCallExpression(expression)) return false;
return (
(ts.isIdentifier(expression.expression) && factories.has(expression.expression.text)) ||
isCreateRequireProperty(expression.expression, moduleObjects)
);
}
function variableDeclarations(sourceFile) {
const declarations = [];
visit(sourceFile);
return declarations;
function visit(node) {
if (ts.isVariableDeclaration(node)) declarations.push(node);
ts.forEachChild(node, visit);
}
}
function literalModuleSpecifier(expression) {
if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression)) {
return expression.text;
}
if (ts.isIdentifier(expression)) return constants.get(expression.text) ?? null;
if (ts.isParenthesizedExpression(expression)) {
return constantString(expression.expression, constants);
return null;
}
function moduleLoad(expression, loaderAliases) {
if (expression.kind === ts.SyntaxKind.ImportKeyword) {
return { loader: "import", operation: "import" };
}
if (ts.isIdentifier(expression) && loaderAliases.has(expression.text)) {
return { loader: expression.text, operation: "load" };
}
if (
ts.isBinaryExpression(expression) &&
expression.operatorToken.kind === ts.SyntaxKind.PlusToken
ts.isPropertyAccessExpression(expression) &&
ts.isIdentifier(expression.expression) &&
loaderAliases.has(expression.expression.text) &&
expression.name.text === "resolve"
) {
const left = constantString(expression.left, constants);
const right = constantString(expression.right, constants);
return left === null || right === null ? null : left + right;
}
if (ts.isTemplateExpression(expression)) {
let value = expression.head.text;
for (const span of expression.templateSpans) {
const substitution = constantString(span.expression, constants);
if (substitution === null) return null;
value += substitution + span.literal.text;
}
return value;
return { loader: expression.expression.text, operation: "resolve" };
}
return null;
}
function createRequireAliases(sourceFile, declarations, moduleObjects) {
const aliases = importedCreateRequireAliases(sourceFile);
let changed = true;
while (changed) {
changed = addCreateRequireAliases(aliases, moduleObjects, declarations);
}
return aliases;
}
function importedCreateRequireAliases(sourceFile) {
const aliases = new Set();
for (const statement of sourceFile.statements) {
if (!isNamedModuleImport(statement)) continue;
for (const element of statement.importClause.namedBindings.elements) {
if ((element.propertyName ?? element.name).text === "createRequire") {
aliases.add(element.name.text);
}
}
}
return aliases;
}
function addCreateRequireAliases(aliases, moduleObjects, declarations) {
let changed = false;
for (const declaration of declarations) {
for (const alias of createRequireBindingNames(declaration, aliases, moduleObjects)) {
if (aliases.has(alias)) continue;
aliases.add(alias);
changed = true;
}
}
return changed;
}
function createRequireBindingNames(declaration, aliases, moduleObjects) {
if (!declaration.initializer) return [];
if (ts.isIdentifier(declaration.name)) {
const initializer = declaration.initializer;
const factory =
(ts.isIdentifier(initializer) && aliases.has(initializer.text)) ||
isCreateRequireProperty(initializer, moduleObjects);
return factory ? [declaration.name.text] : [];
}
if (!ts.isObjectBindingPattern(declaration.name)) return [];
if (!isModuleObjectExpression(declaration.initializer, moduleObjects)) return [];
return declaration.name.elements
.filter(
(element) =>
ts.isIdentifier(element.name) && bindingPropertyName(element) === "createRequire",
)
.map((element) => element.name.text);
}
function isNamedModuleImport(statement) {
return (
ts.isImportDeclaration(statement) &&
statement.importClause?.namedBindings &&
ts.isNamedImports(statement.importClause.namedBindings) &&
ts.isStringLiteral(statement.moduleSpecifier) &&
["module", "node:module"].includes(statement.moduleSpecifier.text)
);
}
function moduleObjectAliases(sourceFile, declarations) {
const aliases = new Set();
for (const statement of sourceFile.statements) {
if (
ts.isImportDeclaration(statement) &&
statement.importClause?.namedBindings &&
ts.isNamespaceImport(statement.importClause.namedBindings) &&
ts.isStringLiteral(statement.moduleSpecifier) &&
["module", "node:module"].includes(statement.moduleSpecifier.text)
) {
aliases.add(statement.importClause.namedBindings.name.text);
}
}
let changed = true;
while (changed) {
changed = false;
for (const declaration of declarations) {
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
if (
!isModuleObjectExpression(declaration.initializer, aliases) ||
aliases.has(declaration.name.text)
) {
continue;
}
aliases.add(declaration.name.text);
changed = true;
}
}
return aliases;
}
function isCreateRequireProperty(expression, moduleObjects) {
return (
staticPropertyName(expression) === "createRequire" &&
isModuleObjectExpression(expression.expression, moduleObjects)
);
}
function isModuleObjectExpression(expression, moduleObjects) {
return (
(ts.isIdentifier(expression) && moduleObjects.has(expression.text)) ||
isModuleRequireCall(expression)
);
}
function staticPropertyName(expression) {
if (ts.isPropertyAccessExpression(expression)) return expression.name.text;
if (ts.isElementAccessExpression(expression) && expression.argumentExpression) {
return literalModuleSpecifier(expression.argumentExpression);
}
return null;
}
function bindingPropertyName(element) {
const property = element.propertyName ?? element.name;
if (ts.isIdentifier(property) || ts.isStringLiteral(property)) return property.text;
if (ts.isComputedPropertyName(property)) return literalModuleSpecifier(property.expression);
return null;
}
function isModuleRequireCall(expression) {
return (
ts.isCallExpression(expression) &&
ts.isIdentifier(expression.expression) &&
expression.expression.text === "require" &&
expression.arguments.length === 1 &&
ts.isStringLiteral(expression.arguments[0]) &&
["module", "node:module"].includes(expression.arguments[0].text)
);
}
function collectNodeRequireParameters(sourceFile, aliases) {
visit(sourceFile);
function visit(node) {
if (
ts.isParameter(node) &&
ts.isIdentifier(node.name) &&
node.type?.getText() === "NodeRequire"
) {
aliases.add(node.name.text);
}
ts.forEachChild(node, visit);
}
}
function enclosingFunctionName(node) {
for (let current = node.parent; current; current = current.parent) {
if (ts.isFunctionDeclaration(current) && current.name) return current.name.text;
if (ts.isMethodDeclaration(current) && ts.isIdentifier(current.name)) return current.name.text;
if (
(ts.isArrowFunction(current) || ts.isFunctionExpression(current)) &&
ts.isVariableDeclaration(current.parent) &&
ts.isIdentifier(current.parent.name)
) {
return current.parent.name.text;
}
}
return "<module>";
}
function isAuditedNonliteralServerLoad(importer, load) {
return auditedNonliteralServerLoads.has(
[importer, load.owner, load.loader, load.operation].join("|"),
);
}
function isPublicEntryPoint(specifier) {
return /workspace-(?:runtime|helper)\/index\.(?:js|ts)$/.test(specifier);
}
@@ -231,11 +459,7 @@ function isForbiddenInternal(importer, specifier) {
);
}
if (target.includes("/workspace-helper/internal/")) {
return !(
importer.endsWith("/workspace-helper/index.ts") ||
importer.includes("/workspace-helper/internal/") ||
importer.endsWith("/workspace-runtime/internal/service.ts")
);
return true;
}
return false;
}
@@ -246,11 +470,16 @@ function isExternalEscape(importer, specifier) {
if (specifier.startsWith("node:") || hostModules.has(specifier) || specifier === "node-pty") {
return false;
}
if (specifier === "@getpaseo/workspace-runtime-contract") return false;
if (
specifier === "@getpaseo/workspace-runtime-contract" ||
specifier === "@getpaseo/workspace-helper"
)
return false;
if (path.posix.isAbsolute(specifier) || specifier.startsWith("file:")) return true;
if (specifier.startsWith(".")) {
const target = path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier));
return !target.startsWith(`${FIXTURE_ROOT}/`);
const owner = IN_REPO_RUNTIME_ROOTS.find((root) => importer.startsWith(`${root}/`));
return !owner || !target.startsWith(`${owner}/`);
}
return (
specifier === "@getpaseo/server" ||
@@ -260,6 +489,20 @@ function isExternalEscape(importer, specifier) {
);
}
function isExternalRuntimeAccess(importer, specifier) {
if (!CORE_SOURCE_ROOTS.some((root) => importer.startsWith(`${root}/`))) return false;
if (/^@getpaseo\/(?:docker|fixture|srt)-workspace-runtime(?:\/|$)/u.test(specifier)) {
return true;
}
if (/paseo-workspace-runtime-(?:docker|srt)|runtimes\/(?:docker|srt)(?:\/|$)/u.test(specifier)) {
return true;
}
const target = resolveSpecifier(importer, specifier);
return IN_REPO_RUNTIME_ROOTS.some(
(runtimeRoot) => target === runtimeRoot || target.startsWith(`${runtimeRoot}/`),
);
}
function resolveSpecifier(importer, specifier) {
if (specifier.startsWith(".")) {
return path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier));
+5 -1
View File
@@ -120,6 +120,9 @@ test("focused contracts stay inside existing required checks", () => {
assert.match(server, /test:hub-cli-contract/);
assert.match(server, /npm test --workspace=@getpaseo\/workspace-runtime-contract/);
assert.match(server, /npm test --workspace=@getpaseo\/workspace-helper/);
assert.match(server, /node --test scripts\/workspace-runtime-standalone\.test\.mjs/);
assert.match(server, /node --test scripts\/server-clean-build\.test\.mjs/);
assert.match(server, /npm run test --workspace=@getpaseo\/server/);
assert.ok(!jobs.has("hub-cli-contract"));
@@ -162,7 +165,8 @@ test("PR routing declares stable behavior ownership", () => {
hub: ["packages/cli/src/commands/hub/**", "packages/server/src/server/hub/**"],
server: [
"packages/server/**",
"packages/fixture-workspace-runtime/**",
"runtimes/fixture/**",
"packages/workspace-helper/**",
"packages/workspace-runtime-contract/**",
"packages/app/e2e/support/fixtures/recording.*",
],
+130
View File
@@ -0,0 +1,130 @@
import { execFile } from "node:child_process";
import { access, cp, mkdir, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { promisify } from "node:util";
const run = promisify(execFile);
const repoRoot = path.resolve(import.meta.dirname, "..");
const packageDirectories = [
"packages/highlight",
"packages/relay",
"packages/protocol",
"packages/client",
"packages/workspace-runtime-contract",
"packages/workspace-helper",
"packages/server",
"packages/cli",
];
test("build:server:clean constructs public prerequisites without runtime implementations", async (t) => {
const isolatedRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-server-clean-build-"));
t.after(() => rm(isolatedRoot, { recursive: true, force: true }));
await copyBuildCheckout(isolatedRoot);
await createNodeModulesOverlay(isolatedRoot);
await createPackageNodeModulesOverlays(isolatedRoot);
for (const relativePath of [
"packages/workspace-runtime-contract/dist",
"packages/workspace-helper/dist",
"packages/server/dist",
]) {
await assert.rejects(access(path.join(isolatedRoot, relativePath)), relativePath);
}
await assert.rejects(access(path.join(isolatedRoot, "runtimes")));
try {
await run("npm", ["run", "build:server:clean"], {
cwd: isolatedRoot,
timeout: 120_000,
});
} catch (error) {
assert.fail(`${error.stdout ?? ""}\n${error.stderr ?? ""}`);
}
for (const relativePath of [
"packages/workspace-runtime-contract/dist/index.js",
"packages/workspace-helper/dist/index.js",
"packages/server/dist/server/server/exports.js",
"packages/cli/dist/index.js",
]) {
await access(path.join(isolatedRoot, relativePath));
}
await assert.rejects(access(path.join(isolatedRoot, "runtimes")));
});
async function copyBuildCheckout(isolatedRoot) {
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,
filter: excludesBuildOutput,
}),
...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: excludesBuildOutput,
});
}),
]);
}
function excludesBuildOutput(source) {
return (
!source.endsWith(`${path.sep}dist`) &&
!source.endsWith(`${path.sep}node_modules`) &&
!source.endsWith(`${path.sep}test-results`)
);
}
async function createNodeModulesOverlay(isolatedRoot) {
const sourceModules = path.join(repoRoot, "node_modules");
const targetModules = path.join(isolatedRoot, "node_modules");
await mkdir(targetModules);
await overlayModules(sourceModules, targetModules);
const internalScope = path.join(targetModules, "@getpaseo");
await mkdir(internalScope);
for (const relativeDirectory of packageDirectories) {
const manifest = JSON.parse(
await readFile(path.join(isolatedRoot, relativeDirectory, "package.json"), "utf8"),
);
await symlink(
path.join(isolatedRoot, relativeDirectory),
path.join(internalScope, manifest.name.slice("@getpaseo/".length)),
"dir",
);
}
}
async function createPackageNodeModulesOverlays(isolatedRoot) {
for (const relativeDirectory of packageDirectories) {
const sourceModules = path.join(repoRoot, relativeDirectory, "node_modules");
try {
await access(sourceModules);
} catch {
continue;
}
const targetModules = path.join(isolatedRoot, relativeDirectory, "node_modules");
await mkdir(targetModules);
await overlayModules(sourceModules, targetModules);
}
}
async function overlayModules(sourceModules, targetModules) {
for (const entry of await readdir(sourceModules, { withFileTypes: true })) {
if (entry.name === "@getpaseo") continue;
await symlink(
path.join(sourceModules, entry.name),
path.join(targetModules, entry.name),
entry.isDirectory() ? "dir" : "file",
);
}
}
+342 -22
View File
@@ -1,5 +1,14 @@
import { execFile } from "node:child_process";
import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import {
access,
copyFile,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
@@ -13,6 +22,77 @@ const run = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const oxlint = path.join(repoRoot, "node_modules/.bin/oxlint");
test("Paseo owns only the public runtime packages and generic fixture", async () => {
const rootPackage = JSON.parse(await readFile(path.join(repoRoot, "package.json"), "utf8"));
assert.ok(rootPackage.workspaces.includes("packages/workspace-helper"));
assert.ok(rootPackage.workspaces.includes("packages/workspace-runtime-contract"));
assert.ok(rootPackage.workspaces.includes("runtimes/fixture"));
assert.ok(!rootPackage.workspaces.includes("runtimes/*"));
for (const script of [
"build:docker-workspace-runtime",
"build:docker-workspace-runtime:clean",
"build:srt-workspace-runtime",
]) {
assert.equal(rootPackage.scripts?.[script], undefined);
}
await assert.rejects(access(path.join(repoRoot, "packages/server/src/server/workspace-helper")));
for (const absent of [
"packages/docker-workspace-runtime",
"packages/fixture-workspace-runtime",
"packages/srt-workspace-runtime",
"runtimes/docker",
"runtimes/srt",
]) {
await assert.rejects(access(path.join(repoRoot, absent)), absent);
}
const helper = JSON.parse(
await readFile(path.join(repoRoot, "packages/workspace-helper/package.json"), "utf8"),
);
assert.equal(helper.name, "@getpaseo/workspace-helper");
assert.equal(helper.private, undefined);
assert.equal(helper.bin?.["paseo-workspace-helper"], "./dist/executable.mjs");
const server = JSON.parse(
await readFile(path.join(repoRoot, "packages/server/package.json"), "utf8"),
);
assert.ok(server.dependencies?.["@getpaseo/workspace-helper"]);
assert.ok(server.dependencies?.["@getpaseo/workspace-runtime-contract"]);
for (const runtime of ["docker", "fixture", "srt"]) {
assert.equal(server.dependencies?.[`@getpaseo/${runtime}-workspace-runtime`], undefined);
}
const fixtureDirectory = path.join(repoRoot, "runtimes/fixture");
const fixture = JSON.parse(await readFile(path.join(fixtureDirectory, "package.json"), "utf8"));
for (const dependency of ["@getpaseo/workspace-runtime-contract", "@getpaseo/workspace-helper"]) {
assert.match(fixture.dependencies?.[dependency] ?? "", /^\d+\.\d+\.\d+(?:[-+].*)?$/u);
}
const fixtureSources = await collectRuntimeSources(fixtureDirectory);
assert.doesNotMatch(
fixtureSources,
/packages\/(?:server|app|cli|desktop|test-utils)|@getpaseo\/server|docker|sandbox-runtime|\bsrt\b/iu,
);
const lockfile = await readFile(path.join(repoRoot, "package-lock.json"), "utf8");
assert.doesNotMatch(
lockfile,
/node_modules\/@getpaseo\/(?:docker|srt)-workspace-runtime|"runtimes\/(?:docker|srt)"/u,
);
for (const relativePath of [
".github/ci-paths.yml",
".github/workflows/ci.yml",
".github/workflows/docker.yml",
"docs/architecture.md",
"docs/docker.md",
"public-docs/configuration.md",
]) {
const contents = await readFile(path.join(repoRoot, relativePath), "utf8");
assert.doesNotMatch(contents, /runtimes\/(?:docker|srt)/u, relativePath);
}
});
test("Oxlint rejects static host imports, re-exports, aliases, and internal package paths", async (t) => {
const root = await fixtureRoot(t);
await copyFile(path.join(repoRoot, ".oxlintrc.json"), path.join(root, ".oxlintrc.json"));
@@ -29,25 +109,25 @@ test("Oxlint rejects static host imports, re-exports, aliases, and internal pack
'export * from "@getpaseo/server/workspace-runtime/internal/service.js";\n' +
'export * from "@getpaseo/server/workspace-runtime/command/internal/command-runtime.js";\n' +
'export * from "@getpaseo/server/workspace-runtime/git-observation/internal/integration.js";\n' +
'export { default as helper } from "../../workspace-helper/internal/client.js";\n' +
'export { default as helper } from "@getpaseo/workspace-helper/internal/client.js";\n' +
"void spawn; void readFile;\n",
);
await source(
root,
"packages/fixture-workspace-runtime/src/forbidden.mjs",
"runtimes/fixture/src/forbidden.mjs",
'import "@getpaseo/server/workspace-runtime/command/internal/command-runtime.js";\n' +
'import { createRequire as externalRequire } from "module";\n' +
"const fixtureLoader = externalRequire(import.meta.url);\n" +
'fixtureLoader("node:fs");\n' +
'export * from "../../../packages/server/src/server/workspace-helper/internal/client.js";\n',
'export * from "@getpaseo/workspace-helper/internal/client.js";\n',
);
const error = await captureFailure(run(oxlint, ["packages"], { cwd: root }));
const error = await captureFailure(run(oxlint, ["packages", "runtimes"], { cwd: root }));
const output = `${error.stdout ?? ""}${error.stderr ?? ""}`;
assert.match(output, /child_process/);
assert.match(output, /node:fs\/promises/);
assert.match(output, /node:module/);
assert.match(output, /'module'/);
assert.match(output, /module/);
assert.match(output, /workspace-runtime\/internal/);
assert.match(output, /workspace-runtime\/command\/internal/);
assert.match(output, /git-observation\/internal/);
@@ -74,7 +154,7 @@ test("the AST guard rejects computed loading and dynamic host/internal bypasses"
);
await source(
root,
"packages/fixture-workspace-runtime/src/forbidden.mjs",
"runtimes/fixture/src/forbidden.mjs",
'require("../../../packages/server/src/server/workspace-runtime/internal/service.js");\n' +
'import("@getpaseo/" + packageName);\n',
);
@@ -88,6 +168,7 @@ test("the AST guard rejects computed loading and dynamic host/internal bypasses"
"module-entrypoint",
"nonliteral-module-load",
"nonliteral-module-load",
"nonliteral-module-load",
"workspace-host-access",
"workspace-host-access",
"workspace-host-access",
@@ -96,6 +177,239 @@ test("the AST guard rejects computed loading and dynamic host/internal bypasses"
);
});
test("the AST guard rejects server access to runtime packages and source roots", async (t) => {
const root = await fixtureRoot(t);
await source(
root,
"packages/server/src/server/runtime-bypass.ts",
'import "@getpaseo/docker-workspace-runtime";\n' +
'export * from "@getpaseo/srt-workspace-runtime";\n' +
'import "../../../../../../paseo-workspace-runtime-docker/src/index.ts";\n' +
'export * from "../../../../../../paseo-workspace-runtime-srt/src/index.mjs";\n' +
'const packageName = "@getpaseo/" + "fixture-workspace-runtime";\n' +
'const sourceRoot = "../../../../runtimes/" + "docker/src/index.ts";\n' +
"const load = require;\n" +
"void load(packageName);\n" +
"void import(sourceRoot);\n",
);
const violations = await findWorkspaceBoundaryViolations(root);
assert.equal(violations.length, 6);
assert.deepEqual(violations.map(({ rule }) => rule).sort(), [
"nonliteral-module-load",
"nonliteral-module-load",
"server-runtime-access",
"server-runtime-access",
"server-runtime-access",
"server-runtime-access",
]);
assert.deepEqual(
new Set(violations.map((violation) => violation.import)),
new Set([
"@getpaseo/docker-workspace-runtime",
"@getpaseo/srt-workspace-runtime",
"../../../../../../paseo-workspace-runtime-docker/src/index.ts",
"../../../../../../paseo-workspace-runtime-srt/src/index.mjs",
"<computed>",
]),
);
});
test("the AST guard rejects every nonliteral production server load", async (t) => {
const root = await fixtureRoot(t);
await source(
root,
"packages/server/src/server/nonliteral-runtime-load.ts",
'const target = ["@getpaseo/docker", "-workspace-runtime"].join("");\n' +
"void import(target);\n",
);
assert.deepEqual(await findWorkspaceBoundaryViolations(root), [
{
file: "packages/server/src/server/nonliteral-runtime-load.ts",
import: "<computed>",
rule: "nonliteral-module-load",
message:
"packages/server/src/server/nonliteral-runtime-load.ts: nonliteral-module-load forbids <computed>",
},
]);
});
test("the AST guard follows createRequire factory origins and chained aliases", async (t) => {
const root = await fixtureRoot(t);
await source(
root,
"packages/server/src/server/named-create-require.ts",
'import { createRequire } from "node:module";\n' +
"const makeLoader = createRequire;\n" +
"const load = makeLoader(import.meta.url);\n" +
"void load(process.env.RUNTIME);\n",
);
await source(
root,
"packages/server/src/server/namespace-create-require.ts",
'import * as moduleApi from "node:module";\n' +
"const makeLoader = moduleApi.createRequire;\n" +
"const makeLoaderAlias = makeLoader;\n" +
"const load = makeLoaderAlias(import.meta.url);\n" +
"const loadAlias = load;\n" +
"void loadAlias(process.env.RUNTIME);\n" +
'const computedFactory = moduleApi["createRequire"];\n' +
"const computedLoad = computedFactory(import.meta.url);\n" +
"void computedLoad(process.env.RUNTIME);\n",
);
await source(
root,
"packages/server/src/server/commonjs-create-require.ts",
'const moduleApi = require("node:module");\n' +
"const makeLoader = moduleApi.createRequire;\n" +
"const makeLoaderAlias = makeLoader;\n" +
"const load = makeLoaderAlias(import.meta.url);\n" +
"const loadAlias = load;\n" +
"void loadAlias(process.env.RUNTIME);\n" +
"const computedFactory = moduleApi[`createRequire`];\n" +
"const computedLoad = computedFactory(import.meta.url);\n" +
"void computedLoad(process.env.RUNTIME);\n",
);
const violations = await findWorkspaceBoundaryViolations(root);
assert.equal(violations.length, 5);
assert.deepEqual(
violations.map(({ file, rule }) => ({ file, rule })),
[
{
file: "packages/server/src/server/commonjs-create-require.ts",
rule: "nonliteral-module-load",
},
{
file: "packages/server/src/server/commonjs-create-require.ts",
rule: "nonliteral-module-load",
},
{
file: "packages/server/src/server/named-create-require.ts",
rule: "nonliteral-module-load",
},
{
file: "packages/server/src/server/namespace-create-require.ts",
rule: "nonliteral-module-load",
},
{
file: "packages/server/src/server/namespace-create-require.ts",
rule: "nonliteral-module-load",
},
],
);
});
test("the AST guard follows direct createRequire property calls", async (t) => {
const root = await fixtureRoot(t);
await source(
root,
"packages/server/src/server/direct-create-require.ts",
'import * as moduleApi from "node:module";\n' +
"const loadA = moduleApi.createRequire(import.meta.url);\n" +
"void loadA(process.env.RUNTIME);\n" +
"\n" +
'const moduleObject = require("node:module");\n' +
"const loadB = moduleObject.createRequire(import.meta.url);\n" +
"void loadB(process.env.RUNTIME);\n" +
"\n" +
'const loadC = moduleApi["createRequire"](import.meta.url);\n' +
"void loadC(process.env.RUNTIME);\n" +
"\n" +
"const loadD = moduleApi[`createRequire`](import.meta.url);\n" +
"void loadD(process.env.RUNTIME);\n",
);
const violations = await findWorkspaceBoundaryViolations(root);
assert.equal(violations.length, 4);
assert.ok(violations.every(({ rule }) => rule === "nonliteral-module-load"));
});
test("audited production loader exceptions are narrow", async (t) => {
const root = await fixtureRoot(t);
await source(
root,
"packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts",
'import { createRequire } from "node:module";\n' +
"function resolveRuntimeCommand(target) {\n" +
' const moduleRequire = createRequire("package.json");\n' +
" return moduleRequire.resolve(target);\n" +
"}\n" +
"function unreviewedCommandLoad(target) {\n" +
' const moduleRequire = createRequire("package.json");\n' +
" return moduleRequire(target);\n" +
"}\n",
);
await source(
root,
"packages/server/src/server/speech/providers/local/sherpa/sherpa-runtime-env.ts",
'import { createRequire } from "node:module";\n' +
"function resolveSherpaLoaderEnv(target) {\n" +
' const require = createRequire("package.json");\n' +
" return require.resolve(target);\n" +
"}\n",
);
await source(
root,
"packages/server/src/server/speech/providers/local/sherpa/sherpa-onnx-node-loader.ts",
"function loadWithRequire(requireFn: NodeRequire, target) {\n" +
" return requireFn(target);\n" +
"}\n",
);
assert.deepEqual(await findWorkspaceBoundaryViolations(root), [
{
file: "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts",
import: "<computed>",
rule: "nonliteral-module-load",
message:
"packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts: nonliteral-module-load forbids <computed>",
},
]);
});
test("the boundary guard rejects a copied helper under any runtime filename", async (t) => {
const root = await fixtureRoot(t);
await source(
root,
"runtimes/fixture/src/innocent-name.mjs",
'const commands = ["fs-stat", "fs-list", "fs-read", "fs-write"]; void commands;\n',
);
assert.deepEqual(await findWorkspaceBoundaryViolations(root), [
{
file: "runtimes/fixture/src/innocent-name.mjs",
import: "workspace-helper protocol",
rule: "duplicate-workspace-helper",
message:
"runtimes/fixture/src/innocent-name.mjs: duplicate-workspace-helper forbids workspace-helper protocol",
},
]);
});
test("the boundary guard rejects a helper protocol split across runtime files", async (t) => {
const root = await fixtureRoot(t);
await source(
root,
"runtimes/fixture/src/read-commands.mjs",
'export const commands = ["fs-stat", "fs-list"];\n',
);
await source(
root,
"runtimes/fixture/src/write-commands.mjs",
'export const commands = ["fs-read", "fs-write"];\n',
);
assert.deepEqual(await findWorkspaceBoundaryViolations(root), [
{
file: "runtimes/fixture",
import: "workspace-helper protocol",
rule: "duplicate-workspace-helper",
message: "runtimes/fixture: duplicate-workspace-helper forbids workspace-helper protocol",
},
]);
});
test("the AST guard rejects every statically resolvable provider probe internal import", async (t) => {
const root = await fixtureRoot(t);
await source(
@@ -116,12 +430,16 @@ test("the AST guard rejects every statically resolvable provider probe internal
const violations = await findWorkspaceBoundaryViolations(root);
assert.equal(violations.length, 8);
assert.deepEqual(new Set(violations.map(({ rule }) => rule)), new Set(["module-entrypoint"]));
assert.deepEqual(
new Set(violations.map(({ rule }) => rule)),
new Set(["module-entrypoint", "nonliteral-module-load"]),
);
assert.deepEqual(
new Set(violations.map((violation) => violation.import)),
new Set([
"../provider-probe/internal/service.js",
"@getpaseo/server/provider-probe/internal/service.js",
"<computed>",
]),
);
});
@@ -150,7 +468,7 @@ test("owned integrations, explicit legacy code, and fixture contract imports pas
root,
"packages/server/src/server/workspace-runtime/internal/service.ts",
'import { git } from "../git-observation/internal/integration.js";\n' +
'import { helper } from "../../workspace-helper/internal/integration/index.js";\n' +
'import { helper } from "@getpaseo/workspace-helper";\n' +
"export const service = [git, helper];\n",
);
await source(
@@ -160,7 +478,7 @@ test("owned integrations, explicit legacy code, and fixture contract imports pas
);
await source(
root,
"packages/fixture-workspace-runtime/src/index.mjs",
"runtimes/fixture/src/index.mjs",
'import { CommandRuntimeControlSchema } from "@getpaseo/workspace-runtime-contract";\n' +
'import { spawn } from "node:child_process";\n' +
'import("./nested.mjs"); void CommandRuntimeControlSchema; void spawn;\n',
@@ -170,20 +488,12 @@ test("owned integrations, explicit legacy code, and fixture contract imports pas
"packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts",
"export const git = 1;\n",
);
await source(
root,
"packages/server/src/server/workspace-helper/internal/integration/index.ts",
"export const helper = 1;\n",
);
await source(
root,
"packages/fixture-workspace-runtime/src/nested.mjs",
"export const nested = 1;\n",
);
await source(root, "packages/workspace-helper/src/index.ts", "export const helper = 1;\n");
await source(root, "runtimes/fixture/src/nested.mjs", "export const nested = 1;\n");
assert.deepEqual(await findWorkspaceBoundaryViolations(root), []);
try {
await run(oxlint, ["packages"], { cwd: root });
await run(oxlint, ["packages", "runtimes"], { cwd: root });
} catch (error) {
assert.fail(`${error.stdout ?? ""}${error.stderr ?? ""}`);
}
@@ -216,7 +526,7 @@ test("strict public schemas and helper argv reject root authority independent of
run(
process.execPath,
[
path.join(repoRoot, "packages/server/src/server/workspace-helper/executable.mjs"),
path.join(repoRoot, "packages/workspace-helper/src/executable.mjs"),
"fs-stat",
`--${key}`,
"/private",
@@ -249,3 +559,13 @@ async function source(root, relativePath, contents) {
await mkdir(path.dirname(filename), { recursive: true });
await writeFile(filename, contents);
}
async function collectRuntimeSources(directory) {
const entries = await readdir(directory, { recursive: true, withFileTypes: true });
const sources = await Promise.all(
entries
.filter((entry) => entry.isFile() && /\.(?:js|mjs|ts|tsx)$/u.test(entry.name))
.map((entry) => readFile(path.join(entry.parentPath, entry.name), "utf8")),
);
return sources.join("\n");
}
@@ -0,0 +1,103 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import assert from "node:assert/strict";
import { promisify } from "node:util";
const run = promisify(execFile);
const repoRoot = path.resolve(import.meta.dirname, "..");
test("the fixture installs and describes from isolated packed dependencies", async (t) => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-fixture-standalone-"));
const managedArtifacts = [];
t.after(async () => {
for (const artifact of managedArtifacts) {
await rm(path.join(repoRoot, artifact.relativePath), { recursive: true, force: true });
}
for (const artifact of managedArtifacts) {
if (artifact.hiddenPath) {
await rename(artifact.hiddenPath, path.join(repoRoot, artifact.relativePath));
}
}
await rm(root, { recursive: true, force: true });
});
const packs = path.join(root, "packs");
await mkdir(packs);
for (const relativePath of buildArtifacts) {
const hiddenPath = path.join(root, "previous-dist", relativePath.replaceAll("/", "__"));
try {
await mkdir(path.dirname(hiddenPath), { recursive: true });
await rename(path.join(repoRoot, relativePath), hiddenPath);
managedArtifacts.push({ relativePath, hiddenPath });
} catch (error) {
if (error.code !== "ENOENT") throw error;
managedArtifacts.push({ relativePath, hiddenPath: null });
}
}
const packageRoots = {
contract: "packages/workspace-runtime-contract",
helper: "packages/workspace-helper",
fixture: "runtimes/fixture",
};
await run("npm", ["run", "build", "--workspace=@getpaseo/fixture-workspace-runtime"], {
cwd: repoRoot,
});
const tarballs = {};
for (const [name, relativeRoot] of Object.entries(packageRoots)) {
const result = JSON.parse(
(
await run("npm", ["pack", "--json", "--pack-destination", packs], {
cwd: path.join(repoRoot, relativeRoot),
})
).stdout,
);
tarballs[name] = path.join(packs, result[0].filename);
}
const project = path.join(root, "fixture");
await mkdir(project);
await writeFile(
path.join(project, "package.json"),
JSON.stringify({
private: true,
dependencies: {
"@getpaseo/workspace-runtime-contract": `file:${tarballs.contract}`,
"@getpaseo/workspace-helper": `file:${tarballs.helper}`,
"@getpaseo/fixture-workspace-runtime": `file:${tarballs.fixture}`,
},
}),
);
await run("npm", ["install", "--ignore-scripts"], {
cwd: project,
env: { ...process.env, npm_config_cache: path.join(root, "npm-cache") },
});
const bin = path.join(project, "node_modules/.bin/paseo-fixture-workspace-runtime");
const description = JSON.parse((await run(bin, ["describe"], { cwd: project })).stdout);
assert.deepEqual(description, {
protocolVersion: 1,
modes: ["pipes", "pty"],
reconcile: false,
});
const bundledHelper = path.join(
project,
"node_modules/@getpaseo/fixture-workspace-runtime/dist/paseo-workspace-helper",
);
assert.deepEqual(JSON.parse((await run(bundledHelper, ["describe"], { cwd: project })).stdout), {
protocolVersion: 1,
version: 1,
capabilities: ["files", "watch", "resolve-command"],
});
assert.match(
await import("node:fs/promises").then(({ realpath }) => realpath(bin)),
/node_modules\/@getpaseo\/fixture-workspace-runtime\//u,
);
});
const buildArtifacts = [
"packages/workspace-runtime-contract/dist",
"packages/workspace-helper/dist",
"runtimes/fixture/dist",
];