From 7c32fcda984605bed084d20497052f1904f069b3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 14 Aug 2026 12:48:35 +0200 Subject: [PATCH] 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. --- .github/ci-paths.yml | 3 +- .github/workflows/ci.yml | 11 +- .github/workflows/docker.yml | 43 +- docker/base/Dockerfile | 2 + docs/architecture.md | 13 + docs/docker.md | 30 +- package-lock.json | 69 +- package.json | 23 +- .../e2e/browser/new-workspace-entry.spec.ts | 2 +- .../app/e2e/support/helpers/e2e-worker.ts | 37 +- .../support/helpers/new-workspace-runtime.ts | 379 +-- .../app/e2e/support/helpers/new-workspace.ts | 3 +- packages/cli/package.json | 1 - .../cli/src/commands/daemon/local-daemon.ts | 16 - packages/client/src/daemon-client.test.ts | 56 - packages/client/src/daemon-client.ts | 2 - .../desktop/e2e/new-workspace-docker.spec.ts | 162 -- .../desktop/e2e/new-workspace-runtime.spec.ts | 2 +- packages/desktop/package.json | 5 +- packages/desktop/playwright.config.ts | 3 - .../scripts/dev-build-contract.test.mjs | 4 +- packages/desktop/src/daemon/daemon-manager.ts | 9 - .../src/daemon/desktop-packaging.test.ts | 8 +- packages/docker-workspace-runtime/Dockerfile | 20 - .../docker-workspace-runtime/package.json | 23 - .../docker-workspace-runtime/src/index.ts | 1157 --------- .../docker-workspace-runtime/src/workload.mjs | 60 - .../fixture-workspace-runtime/package.json | 18 - packages/server/package.json | 5 +- .../acp-workspace-runtime.posix.test.ts | 4 +- .../server/agent/providers/workspace/index.ts | 2 +- packages/server/src/server/bootstrap.ts | 2 +- packages/server/src/server/config.test.ts | 2 +- ...space-runtime-characterization.e2e.test.ts | 240 +- .../src/server/paseo-worktree-service.ts | 2 +- .../server/provider-probe/internal/service.ts | 1 - .../provider-probe.posix.test.ts | 1 - packages/server/src/server/session.ts | 64 +- .../src/server/session.workspaces.test.ts | 12 +- .../session/files/workspace-files-session.ts | 2 +- .../workspace-scripts-service.ts | 9 +- ...orkspace-archive-record-scoped.e2e.test.ts | 44 +- ...ce-git-service.runtime.integration.test.ts | 8 +- .../command/internal/command-runtime.ts | 38 +- .../server/workspace-runtime/drivers/index.ts | 2 + .../git-observation/internal/integration.ts | 2 +- .../src/server/workspace-runtime/index.ts | 11 +- .../workspace-runtime/internal/host-helper.ts | 6 +- .../workspace-runtime/internal/service.ts | 106 +- .../internal/worktree-runtime.ts | 10 +- .../workspace-runtime-boundary.test.ts | 43 +- .../workspace-runtime.command.test.ts | 62 +- .../workspace-runtime.docker.e2e.test.ts | 2112 ----------------- .../workspace-runtime.posix.test.ts | 72 +- .../src/server/worktree-session.test.ts | 36 + .../server/src/server/worktree-session.ts | 122 +- .../server/src/utils/string-command-shell.ts | 4 +- .../utils/worktree-shell-selection.test.ts | 100 +- packages/server/src/utils/worktree.ts | 275 ++- packages/workspace-helper/README.md | 12 + packages/workspace-helper/package.json | 38 + .../src/binding.ts} | 5 +- .../src}/client.ts | 4 +- .../src}/executable.mjs | 2 +- .../src/files.ts} | 0 packages/workspace-helper/src/index.ts | 3 + packages/workspace-helper/src/location.ts | 6 + .../src}/protocol.ts | 0 .../test}/fixtures/adversarial-helper.mjs | 1 + .../workspace-helper/test/standalone.test.mjs | 90 + .../test}/workspace-helper.posix.test.ts | 52 +- .../tsconfig.json | 5 +- packages/workspace-runtime-contract/README.md | 13 +- .../examples/v1.json | 12 +- .../workspace-runtime-contract/src/index.ts | 13 +- .../test/contract.test.mjs | 28 +- public-docs/configuration.md | 21 +- runtimes/fixture/package.json | 23 + .../fixture}/src/index.mjs | 35 +- .../fixtures/workspace-runtime-acp-agent.mjs | 0 .../check-workspace-runtime-boundaries.mjs | 431 +++- scripts/ci-workflow.test.mjs | 6 +- scripts/server-clean-build.test.mjs | 130 + scripts/workspace-runtime-boundaries.test.mjs | 364 ++- scripts/workspace-runtime-standalone.test.mjs | 103 + 85 files changed, 2293 insertions(+), 4664 deletions(-) delete mode 100644 packages/desktop/e2e/new-workspace-docker.spec.ts delete mode 100644 packages/docker-workspace-runtime/Dockerfile delete mode 100644 packages/docker-workspace-runtime/package.json delete mode 100644 packages/docker-workspace-runtime/src/index.ts delete mode 100644 packages/docker-workspace-runtime/src/workload.mjs delete mode 100644 packages/fixture-workspace-runtime/package.json delete mode 100644 packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts create mode 100644 packages/workspace-helper/README.md create mode 100644 packages/workspace-helper/package.json rename packages/{server/src/server/workspace-helper/internal/integration/index.ts => workspace-helper/src/binding.ts} (77%) rename packages/{server/src/server/workspace-helper/internal => workspace-helper/src}/client.ts (99%) rename packages/{server/src/server/workspace-helper => workspace-helper/src}/executable.mjs (99%) mode change 100644 => 100755 rename packages/{server/src/server/workspace-helper/index.ts => workspace-helper/src/files.ts} (100%) create mode 100644 packages/workspace-helper/src/index.ts create mode 100644 packages/workspace-helper/src/location.ts rename packages/{server/src/server/workspace-helper/internal => workspace-helper/src}/protocol.ts (100%) rename packages/{server/src/server/workspace-helper => workspace-helper/test}/fixtures/adversarial-helper.mjs (95%) create mode 100644 packages/workspace-helper/test/standalone.test.mjs rename packages/{server/src/server/workspace-helper => workspace-helper/test}/workspace-helper.posix.test.ts (88%) rename packages/{docker-workspace-runtime => workspace-helper}/tsconfig.json (77%) create mode 100644 runtimes/fixture/package.json rename {packages/fixture-workspace-runtime => runtimes/fixture}/src/index.mjs (96%) rename {packages/server/src/server/test-utils => runtimes/fixture/test}/fixtures/workspace-runtime-acp-agent.mjs (100%) create mode 100644 scripts/server-clean-build.test.mjs create mode 100644 scripts/workspace-runtime-standalone.test.mjs diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index d53ecfc778..cc23e1c96c 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -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.*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f7eacd42f..427b366b85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 51c1a52305..ab825ddbc0 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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' diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 562b898ca9..860891a14e 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 08f3fd8d66..820324d359 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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` diff --git a/docs/docker.md b/docs/docker.md index ff0501e483..fdb7a01a83 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 80827dc0c3..2d2385bc6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index c1bcfc8cb3..645bbdabf6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/app/e2e/browser/new-workspace-entry.spec.ts b/packages/app/e2e/browser/new-workspace-entry.spec.ts index f15b668e4d..1c012e54ad 100644 --- a/packages/app/e2e/browser/new-workspace-entry.spec.ts +++ b/packages/app/e2e/browser/new-workspace-entry.spec.ts @@ -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(); diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index e858471750..13c5b4b53a 100644 --- a/packages/app/e2e/support/helpers/e2e-worker.ts +++ b/packages/app/e2e/support/helpers/e2e-worker.ts @@ -148,13 +148,8 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise 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 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 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"), diff --git a/packages/app/e2e/support/helpers/new-workspace-runtime.ts b/packages/app/e2e/support/helpers/new-workspace-runtime.ts index e346eac56e..012c874233 100644 --- a/packages/app/e2e/support/helpers/new-workspace-runtime.ts +++ b/packages/app/e2e/support/helpers/new-workspace-runtime.ts @@ -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; } -interface HostDecoySnapshot { - content: string; - mtimeMs: number; -} - -export interface SeededDockerRuntimeProject extends SeededRuntimeProject { - hostMutationPath: string; - expectHostDecoysUntouched(): Promise; - mutateHostAfterDockerCreation(): Promise; -} - -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 { - 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(); - 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> { 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 await expect(page.getByText("Finder", { exact: true })).toBeVisible(); await page.keyboard.press("Escape"); } - -export async function expectDockerHasNoHostAffordance(page: Page): Promise { - 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 { - 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 { - await expect.poll(() => getTerminalBufferText(page), { timeout: 30_000 }).toContain(text); -} - -export async function expectDockerScriptTerminalOpen( - page: Page, - scriptName: string, -): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - await expect - .poll(() => dockerRuntimeLeaks(workspaceIds, names), { timeout: 15_000 }) - .toEqual({ containers: [], processes: [], volumes: [] }); -} - -async function cleanupValidatedDockerResources( - workspaceIds: readonly string[], - runtimeOwner: string, -): Promise { - 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; - name: string; -} - -async function inspectDockerTestResource( - kind: "container" | "volume", - name: string, -): Promise { - try { - const { stdout } = await run("docker", [kind, "inspect", name]); - const [inspection] = JSON.parse(stdout) as Array<{ - Config?: { Labels?: Record | null }; - Labels?: Record | 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 { - 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 { - 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 { - 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); -} diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 512b519355..2663557217 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -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 = { - docker: "Docker", local: "Local", worktree: "Worktree", }; diff --git a/packages/cli/package.json b/packages/cli/package.json index a634149f9a..04be4e1b6c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/commands/daemon/local-daemon.ts b/packages/cli/src/commands/daemon/local-daemon.ts index b22fa24184..947af958d6 100644 --- a/packages/cli/src/commands/daemon/local-daemon.ts +++ b/packages/cli/src/commands/daemon/local-daemon.ts @@ -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; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 0efb6b4862..43ef54b3b2 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -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(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 72bcdf0f8e..1dd3db5619 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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 } : {}), }); } diff --git a/packages/desktop/e2e/new-workspace-docker.spec.ts b/packages/desktop/e2e/new-workspace-docker.spec.ts deleted file mode 100644 index f5cc89c59b..0000000000 --- a/packages/desktop/e2e/new-workspace-docker.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/desktop/e2e/new-workspace-runtime.spec.ts b/packages/desktop/e2e/new-workspace-runtime.spec.ts index d8c1388ba1..0cb527706b 100644 --- a/packages/desktop/e2e/new-workspace-runtime.spec.ts +++ b/packages/desktop/e2e/new-workspace-runtime.spec.ts @@ -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"); }); diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 51aaee5df3..0d0617280e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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", diff --git a/packages/desktop/playwright.config.ts b/packages/desktop/playwright.config.ts index 4d0561edd5..b97057c230 100644 --- a/packages/desktop/playwright.config.ts +++ b/packages/desktop/playwright.config.ts @@ -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: { diff --git a/packages/desktop/scripts/dev-build-contract.test.mjs b/packages/desktop/scripts/dev-build-contract.test.mjs index 5ee938162f..524135f534 100644 --- a/packages/desktop/scripts/dev-build-contract.test.mjs +++ b/packages/desktop/scripts/dev-build-contract.test.mjs @@ -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 { diff --git a/packages/desktop/src/daemon/daemon-manager.ts b/packages/desktop/src/daemon/daemon-manager.ts index f9453a50ee..faf4a7a101 100644 --- a/packages/desktop/src/daemon/daemon-manager.ts +++ b/packages/desktop/src/daemon/daemon-manager.ts @@ -398,15 +398,6 @@ async function startDaemon(): Promise { PASEO_DESKTOP_MANAGED: "1", PASEO_CLI: getBundledCliShimPath(), PASEO_WEB_UI_ENABLED: "false", - PASEO_DISTRIBUTION_WORKSPACE_RUNTIMES: JSON.stringify({ - docker: { - type: "command", - label: "Docker", - command: ["@getpaseo/docker-workspace-runtime"], - options: { image: "paseo-workspace-runtime:phase3-qa", bindMounts: [] }, - }, - }), - PASEO_DISTRIBUTION_PACKAGE_ROOT: __dirname, }, stdio: ["ignore", "ignore", "ignore"], }); diff --git a/packages/desktop/src/daemon/desktop-packaging.test.ts b/packages/desktop/src/daemon/desktop-packaging.test.ts index 85d5d2a9cf..66398783a9 100644 --- a/packages/desktop/src/daemon/desktop-packaging.test.ts +++ b/packages/desktop/src/daemon/desktop-packaging.test.ts @@ -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", () => { diff --git a/packages/docker-workspace-runtime/Dockerfile b/packages/docker-workspace-runtime/Dockerfile deleted file mode 100644 index 3a4f747d79..0000000000 --- a/packages/docker-workspace-runtime/Dockerfile +++ /dev/null @@ -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 diff --git a/packages/docker-workspace-runtime/package.json b/packages/docker-workspace-runtime/package.json deleted file mode 100644 index 1bbc227b70..0000000000 --- a/packages/docker-workspace-runtime/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/docker-workspace-runtime/src/index.ts b/packages/docker-workspace-runtime/src/index.ts deleted file mode 100644 index 78614424ea..0000000000 --- a/packages/docker-workspace-runtime/src/index.ts +++ /dev/null @@ -1,1157 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; -import { realpath } from "node:fs/promises"; -import { request as httpRequest } from "node:http"; -import { Socket } from "node:net"; -import path from "node:path"; -import { createInterface } from "node:readline"; -import type { Duplex } from "node:stream"; -import { - COMMAND_RUNTIME_PROTOCOL_VERSION, - CommandRuntimeControlSchema, - CommandRuntimeDescribeResponseSchema, - CommandRuntimeLifecycleRequestSchema, - CommandRuntimeLifecycleResponseSchema, - CommandRuntimeProcessEventSchema, - encodeCommandRuntimeMessage, - type CommandRuntimeControl, - type CommandRuntimeLifecycleRequest, - type CommandRuntimeMessageSchema, - type CommandRuntimeSpawnEnvelope, - type CommandRuntimeState, -} from "@getpaseo/workspace-runtime-contract"; - -type RuntimeState = CommandRuntimeState; -type PrivateRuntimeState = RuntimeState & { - root: string; - revision: string; - container: string; - lifecycleEnvironment: Readonly>; -}; -type LifecycleRequest = CommandRuntimeLifecycleRequest; -type ExecRequest = CommandRuntimeSpawnEnvelope; -type PtyControl = CommandRuntimeControl; -interface DockerBindMount { - source: string; - target: string; - readOnly: boolean; -} - -const PTY_CLEANUP_TIMEOUT_MS = 1_000; -const EXEC_READY_TIMEOUT_MS = 5_000; - -const [operation] = process.argv.slice(2); -const requestedWorkspaceId = argument("--workspace-id"); - -try { - if (operation === "describe") { - writeJson(CommandRuntimeDescribeResponseSchema, { - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - modes: ["pipes", "pty"], - reconcile: true, - }); - } else if (operation === "reconcile") { - const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin())); - await reconcile(request.workspaceIds ?? [], requireOwner(request.runtimeInstanceId)); - writeJson(CommandRuntimeLifecycleResponseSchema, { - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - type: "ok", - }); - } else if (!requestedWorkspaceId) { - throw new Error("--workspace-id is required"); - } else if (operation === "exec") { - await execute(requestedWorkspaceId); - } else if (operation === "signal") { - const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin())); - await signalExec( - requestedWorkspaceId, - requireArgument("--exec-id"), - requireSignal(requireArgument("--signal")), - requireOwner(request.runtimeInstanceId), - ); - } else { - const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin())); - switch (operation) { - case "create": - { - const result = await create(requestedWorkspaceId, request); - writeJson(CommandRuntimeLifecycleResponseSchema, { - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - type: "state", - state: publicState(result.state), - placement: runtimePlacement(result.state), - materializedFreshContent: result.materializedFreshContent, - }); - } - break; - case "inspect": - writeJson(CommandRuntimeLifecycleResponseSchema, { - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - type: "inspection", - inspection: await inspect(requestedWorkspaceId, requireOwner(request.runtimeInstanceId)), - }); - break; - case "pause": - await pause(requestedWorkspaceId, requireOwner(request.runtimeInstanceId)); - writeJson(CommandRuntimeLifecycleResponseSchema, { - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - type: "ok", - }); - break; - case "resume": - { - const state = await resume(requestedWorkspaceId, requireOwner(request.runtimeInstanceId)); - writeJson(CommandRuntimeLifecycleResponseSchema, { - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - type: "state", - state: publicState(state), - placement: runtimePlacement(state), - }); - } - break; - case "destroy": - await destroy(requestedWorkspaceId, requireOwner(request.runtimeInstanceId)); - writeJson(CommandRuntimeLifecycleResponseSchema, { - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - type: "ok", - }); - break; - default: - throw new Error(`Unknown operation: ${operation}`); - } - } -} catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -} - -async function create( - workspaceId: string, - request: LifecycleRequest, -): Promise<{ state: PrivateRuntimeState; materializedFreshContent: boolean }> { - const owner = requireOwner(request.runtimeInstanceId); - const existing = await inspectPrivate(workspaceId, owner); - if (existing.status === "ready" || existing.status === "paused") { - return { state: existing.state, materializedFreshContent: false }; - } - const input = request.input; - if (!input) throw new Error("create input is required"); - const image = requireImage(request.options); - const bindMounts = requireBindMounts(request.options); - const relativeRoot = - input.project.source.kind === "git" - ? input.project.source.subdirectory - : input.placement.relativeCwd; - const requestedRoot = resolveWorkspaceCwd("/workspace", relativeRoot); - const names = resourceNames(workspaceId); - await docker([ - "volume", - "create", - "--label", - "sh.paseo.runtime=workspace", - "--label", - `sh.paseo.workspace-id=${workspaceId}`, - "--label", - `sh.paseo.runtime-owner=${owner}`, - names.volume, - ]); - const createdVolume = await inspectVolume(names.volume); - if (!createdVolume) throw new Error(`Docker volume disappeared after creation: ${names.volume}`); - assertOwnedResource("volume", names.volume, createdVolume, workspaceId, owner); - - try { - const source = input.project.source; - const cloneSource = source.kind === "host-directory" ? "/source" : source.url; - const revision = source.kind === "git" ? source.revision : ""; - const mounts = ["-v", `${names.volume}:/workspace`]; - if (source.kind === "host-directory") { - mounts.push("-v", `${await realpath(source.path)}:/source:ro`); - } - await docker([ - "run", - "--rm", - ...mounts, - ...dockerBindMountArguments(bindMounts), - image, - "/bin/sh", - "-c", - 'git clone --no-hardlinks "$1" /workspace && if [ -n "$2" ]; then git -C /workspace checkout --detach "$2"; fi', - "paseo-materialize", - cloneSource, - revision, - ]); - const commit = ( - await docker([ - "run", - "--rm", - "-v", - `${names.volume}:/workspace`, - image, - "git", - "-C", - "/workspace", - "rev-parse", - "HEAD", - ]) - ).trim(); - const root = ( - await docker([ - "run", - "--rm", - "-v", - `${names.volume}:/workspace`, - image, - "/bin/sh", - "-c", - 'cd "$1" && pwd -P', - "paseo-resolve-root", - requestedRoot, - ]) - ).trim(); - assertContainedPath("/workspace", root); - await docker([ - "create", - "--init", - "--name", - names.container, - "--label", - "sh.paseo.runtime=workspace", - "--label", - `sh.paseo.workspace-id=${workspaceId}`, - "--label", - `sh.paseo.runtime-owner=${owner}`, - "--label", - `sh.paseo.workspace-root=${root}`, - "--label", - `sh.paseo.workspace-revision=${commit}`, - "-v", - `${names.volume}:/workspace`, - ...dockerBindMountArguments(bindMounts), - image, - "/bin/sh", - "-c", - "exec tail -f /dev/null", - ]); - await assertOwnedWorkspaceResources(workspaceId, owner); - await docker(["start", names.container]); - await waitUntilReady(names.container); - return { - state: stateFor(workspaceId, root, commit, names.container), - materializedFreshContent: true, - }; - } catch (error) { - const cleanupErrors = await cleanupOwnedCreation(workspaceId, owner); - if (cleanupErrors.length > 0) { - throw new Error( - `Docker creation rollback failed: ${cleanupErrors.map((cleanupError) => cleanupError.message).join("; ")}`, - { cause: error }, - ); - } - throw error; - } -} - -interface DockerResourceLabels { - runtime: string | null; - workspaceId: string | null; - runtimeOwner: string | null; -} - -interface DockerContainerInspection extends DockerResourceLabels { - running: boolean; - root: string | null; - revision: string | null; -} - -async function assertOwnedWorkspaceResources( - workspaceId: string, - expectedOwner: string, -): Promise { - const names = resourceNames(workspaceId); - const [container, volume] = await Promise.all([ - inspectContainer(names.container), - inspectVolume(names.volume), - ]); - if (!container || !volume) { - throw new Error(`Docker workspace resources are incomplete: ${workspaceId}`); - } - assertOwnedResource("container", names.container, container, workspaceId, expectedOwner); - assertOwnedResource("volume", names.volume, volume, workspaceId, expectedOwner); -} - -async function inspectPrivate( - workspaceId: string, - expectedOwner: string, -): Promise< - | { status: "missing" } - | { - status: "ready" | "paused"; - state: PrivateRuntimeState; - placement: { cwd: string }; - } -> { - const names = resourceNames(workspaceId); - const [container, volume] = await Promise.all([ - inspectContainer(names.container), - inspectVolume(names.volume), - ]); - if (container) - assertOwnedResource("container", names.container, container, workspaceId, expectedOwner); - if (volume) assertOwnedResource("volume", names.volume, volume, workspaceId, expectedOwner); - if (!container && !volume) return { status: "missing" }; - if (!container || !volume) { - throw new Error(`Docker workspace resources are incomplete: ${workspaceId}`); - } - if (!container.root || !container.revision) { - throw new Error(`Docker container placement is incomplete: ${names.container}`); - } - const state = stateFor( - workspaceId, - container.root, - container.revision, - names.container, - container.running ? "ready" : "paused", - ); - return { - status: container.running ? "ready" : "paused", - state, - placement: runtimePlacement(state), - }; -} - -async function inspectContainer(name: string): Promise { - const output = await docker( - [ - "inspect", - "--format", - '{{json .State.Running}}|{{json (index .Config.Labels "sh.paseo.runtime")}}|{{json (index .Config.Labels "sh.paseo.workspace-id")}}|{{json (index .Config.Labels "sh.paseo.runtime-owner")}}|{{json (index .Config.Labels "sh.paseo.workspace-root")}}|{{json (index .Config.Labels "sh.paseo.workspace-revision")}}', - name, - ], - true, - ); - if (!output) return null; - const [running, runtime, workspaceId, runtimeOwner, root, revision] = output - .trim() - .split("|") - .map(parseDockerJsonValue); - return { - running: running === true, - runtime: typeof runtime === "string" ? runtime : null, - workspaceId: typeof workspaceId === "string" ? workspaceId : null, - runtimeOwner: typeof runtimeOwner === "string" ? runtimeOwner : null, - root: typeof root === "string" ? root : null, - revision: typeof revision === "string" ? revision : null, - }; -} - -async function inspect(workspaceId: string, expectedOwner: string) { - const inspection = await inspectPrivate(workspaceId, expectedOwner); - if (inspection.status === "ready" || inspection.status === "paused") { - return { ...inspection, state: publicState(inspection.state) }; - } - return inspection; -} - -async function pause(workspaceId: string, expectedOwner: string): Promise { - const current = await inspectPrivate(workspaceId, expectedOwner); - if (current.status === "missing" || current.status === "paused") return; - await docker(["stop", "--time", "5", resourceNames(workspaceId).container]); -} - -async function resume(workspaceId: string, expectedOwner: string): Promise { - const current = await inspectPrivate(workspaceId, expectedOwner); - if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`); - if (current.status === "paused") await docker(["start", resourceNames(workspaceId).container]); - await waitUntilReady(resourceNames(workspaceId).container); - const ready = await inspectPrivate(workspaceId, expectedOwner); - if (ready.status !== "ready") throw new Error(`Docker workspace did not resume: ${workspaceId}`); - return ready.state; -} - -async function destroy(workspaceId: string, expectedOwner: string): Promise { - const names = resourceNames(workspaceId); - const current = await inspectPrivate(workspaceId, expectedOwner); - if (current.status === "missing") return; - await docker(["rm", "-f", names.container]); - await docker(["volume", "rm", names.volume]); -} - -async function inspectVolume(name: string): Promise { - const output = await docker(["volume", "inspect", name], true); - if (!output) return null; - const inspection = JSON.parse(output) as Array<{ Labels?: Record | null }>; - return { - workspaceId: inspection[0]?.Labels?.["sh.paseo.workspace-id"] ?? null, - runtime: inspection[0]?.Labels?.["sh.paseo.runtime"] ?? null, - runtimeOwner: inspection[0]?.Labels?.["sh.paseo.runtime-owner"] ?? null, - }; -} - -async function reconcile(workspaceIds: readonly string[], owner: string): Promise { - const known = new Set(workspaceIds); - const containers = await labeledResources("ps", owner); - const volumes = await labeledResources("volume", owner); - const candidateWorkspaceIds = new Set([...containers.values(), ...volumes.values()]); - for (const workspaceId of candidateWorkspaceIds) { - if (!workspaceId || known.has(workspaceId)) continue; - const names = resourceNames(workspaceId); - const containerProven = containers.get(names.container) === workspaceId; - const volumeProven = volumes.get(names.volume) === workspaceId; - if (!containerProven && !volumeProven) continue; - const container = await inspectContainer(names.container); - const volume = await inspectVolume(names.volume); - if (container) assertOwnedResource("container", names.container, container, workspaceId, owner); - if (volume) assertOwnedResource("volume", names.volume, volume, workspaceId, owner); - if (containerProven && container) await docker(["rm", "-f", names.container]); - if (volumeProven && volume) await docker(["volume", "rm", names.volume]); - } -} - -async function labeledResources( - kind: "ps" | "volume", - owner: string, -): Promise> { - const output = - kind === "ps" - ? await docker([ - "ps", - "-a", - "--filter", - "label=sh.paseo.runtime=workspace", - "--filter", - `label=sh.paseo.runtime-owner=${owner}`, - "--format", - '{{.Names}}|{{.Label "sh.paseo.workspace-id"}}', - ]) - : await docker([ - "volume", - "ls", - "--filter", - "label=sh.paseo.runtime=workspace", - "--filter", - `label=sh.paseo.runtime-owner=${owner}`, - "--format", - '{{.Name}}|{{.Label "sh.paseo.workspace-id"}}', - ]); - return new Map( - output - .trim() - .split("\n") - .filter(Boolean) - .map((line) => { - const separator = line.indexOf("|"); - return separator < 0 ? [line, ""] : [line.slice(0, separator), line.slice(separator + 1)]; - }), - ); -} - -async function execute(workspaceId: string): Promise { - const control = new Socket({ fd: 3, readable: true, writable: false }); - const lines = createInterface({ input: control, crlfDelay: Infinity }); - const iterator = lines[Symbol.asyncIterator](); - const first = await iterator.next(); - if (first.done) throw new Error("Runtime exec spawn control is required"); - const request = CommandRuntimeControlSchema.parse(JSON.parse(first.value)); - if (request.type !== "spawn") throw new Error("Runtime exec spawn control is required"); - if (!/^[a-f0-9]{32}$/.test(request.execId)) throw new Error("Invalid exec id"); - const expectedOwner = requireOwner(request.runtimeInstanceId); - const current = await inspectPrivate(workspaceId, expectedOwner); - if (current.status !== "ready") throw new Error(`Docker workspace is ${current.status}`); - const requestedCwd = resolveWorkspaceCwd(current.state.root, request.cwd); - const cwd = ( - await docker([ - "exec", - resourceNames(workspaceId).container, - "/bin/sh", - "-c", - 'cd "$1" && pwd -P', - "paseo-resolve-cwd", - requestedCwd, - ]) - ).trim(); - assertContainedPath(current.state.root, cwd); - const encoded = Buffer.from(JSON.stringify({ ...request, cwd })).toString("base64url"); - const execId = request.execId; - const container = resourceNames(workspaceId).container; - const readiness = await beginExecutionReadiness(container, execId); - if (request.stdio.kind === "pty") { - try { - await executeDockerPty({ - workspaceId, - request, - controls: iterator, - encoded, - container, - readiness, - }); - } finally { - await readiness.close(); - } - return; - } - const events = new Socket({ fd: 4, readable: false, writable: true }); - const child = spawn( - "docker", - [ - "exec", - "-i", - "-e", - "PASEO_RUNTIME_EXEC", - "-e", - "PASEO_RUNTIME_EXEC_ID", - container, - "node", - "/opt/paseo-workspace-runtime/workload.mjs", - ], - { - env: { ...process.env, PASEO_RUNTIME_EXEC: encoded, PASEO_RUNTIME_EXEC_ID: execId }, - stdio: ["inherit", "inherit", "inherit"], - }, - ); - const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( - (resolve, reject) => { - child.once("error", reject); - child.once("exit", (code, signal) => resolve({ code, signal })); - }, - ); - let forwardedSignal: NodeJS.Signals | null = null; - let forwardedSignalCompletion: Promise | null = null; - let forwardedSignalFailure: unknown; - const forwardSignal = async (signal: NodeJS.Signals): Promise => { - forwardedSignal = signal; - try { - await withTimeout( - signalExec(workspaceId, execId, signal, expectedOwner), - PTY_CLEANUP_TIMEOUT_MS, - "Docker signal helper timed out", - ); - } catch (error) { - if (signal === "SIGKILL") throw error; - try { - await withTimeout( - signalExec(workspaceId, execId, "SIGKILL", expectedOwner), - PTY_CLEANUP_TIMEOUT_MS, - "Docker forced signal helper timed out", - ); - } catch (fallbackError) { - throw new Error(`Docker signal cleanup failed after ${String(error)}`, { - cause: fallbackError, - }); - } - } - }; - const hostSignalHandlers = new Map void>(); - for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { - const handler = () => { - forwardedSignalCompletion = forwardSignal(signal).catch((error) => { - forwardedSignalFailure = error; - }); - }; - hostSignalHandlers.set(signal, handler); - process.on(signal, handler); - } - let exit: { code: number | null; signal: NodeJS.Signals | null }; - try { - await withTimeout( - readiness.ready, - EXEC_READY_TIMEOUT_MS, - "Docker workload readiness timed out", - ); - writePtyEvent(events, { - type: "started", - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - }); - exit = await exitPromise; - if (forwardedSignalCompletion) await forwardedSignalCompletion; - if (forwardedSignalFailure) throw forwardedSignalFailure; - } catch (error) { - child.kill("SIGKILL"); - await readiness.close(); - throw error; - } - for (const [signal, handler] of hostSignalHandlers) process.off(signal, handler); - writePtyEvent(events, { type: "eof", protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION }); - await new Promise((resolve, reject) => { - events - .end( - encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { - type: "exit", - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - code: forwardedSignal || exit.signal ? null : (exit.code ?? 1), - signal: forwardedSignal ?? exit.signal, - }), - resolve, - ) - .once("error", reject); - }); - await readiness.close(); - if (forwardedSignal) { - process.removeAllListeners(forwardedSignal); - process.kill(process.pid, forwardedSignal); - return; - } - if (exit.signal) { - process.kill(process.pid, exit.signal); - return; - } - process.exitCode = exit.code ?? 1; -} - -async function executeDockerPty(input: { - workspaceId: string; - request: ExecRequest; - controls: AsyncIterator; - encoded: string; - container: string; - readiness: ExecutionReadiness; -}): Promise { - if (input.request.stdio.kind !== "pty") throw new Error("Docker PTY execution requires PTY mode"); - const events = new Socket({ fd: 4, readable: false, writable: true }); - const created = await dockerApiJson<{ Id: string }>( - "POST", - `/containers/${encodeURIComponent(input.container)}/exec`, - { - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: true, - Env: [`PASEO_RUNTIME_EXEC=${input.encoded}`, `PASEO_RUNTIME_EXEC_ID=${input.request.execId}`], - Cmd: ["node", "/opt/paseo-workspace-runtime/workload.mjs"], - }, - ); - const stream = await startDockerExec(created.Id); - await resizeDockerExec(created.Id, input.request.stdio.cols, input.request.stdio.rows); - await withTimeout( - input.readiness.ready, - EXEC_READY_TIMEOUT_MS, - "Docker workload readiness timed out", - ); - writePtyEvent(events, { - type: "started", - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - }); - process.stdin.pipe(stream); - stream.on("data", (data: Buffer) => process.stdout.write(data)); - stream.resume(); - let requestedSignal: NodeJS.Signals | null = null; - let executionFinished = false; - let announceControlFailure!: (error: Error) => void; - const controlFailureStarted = new Promise((resolve) => { - announceControlFailure = resolve; - }); - const controlCleanup = handlePtyControls( - input.workspaceId, - input.request.execId, - created.Id, - input.controls, - requireOwner(input.request.runtimeInstanceId), - (signal) => { - requestedSignal = signal; - }, - (id) => { - writePtyEvent(events, { - type: "resized", - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - id, - }); - }, - ).then( - () => new Promise(() => {}), - async (error) => { - if (executionFinished) return new Promise(() => {}); - const failure = error instanceof Error ? error : new Error(String(error)); - announceControlFailure(failure); - let cleanupError: Error | null = null; - try { - await withTimeout( - signalExec( - input.workspaceId, - input.request.execId, - "SIGKILL", - requireOwner(input.request.runtimeInstanceId), - ), - PTY_CLEANUP_TIMEOUT_MS, - "Docker PTY signal helper timed out", - ); - } catch (cleanupFailure) { - cleanupError = - cleanupFailure instanceof Error ? cleanupFailure : new Error(String(cleanupFailure)); - } - stream.destroy(); - return { failure, cleanupError }; - }, - ); - const streamEnded = new Promise((resolve, reject) => { - stream.once("end", resolve); - stream.once("error", reject); - }); - const firstOutcome = await Promise.race([ - streamEnded.then(() => "stream" as const), - controlFailureStarted.then(() => "control" as const), - ]); - const outcome = - firstOutcome === "control" ? await controlCleanup : { failure: null, cleanupError: null }; - executionFinished = true; - process.stdin.unpipe(stream); - writePtyEvent(events, { type: "eof", protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION }); - if (outcome.failure) { - const cleanupDetail = outcome.cleanupError - ? `; cleanup failed: ${outcome.cleanupError.message}` - : ""; - writePtyEvent(events, { - type: "error", - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - message: `${outcome.failure.message}${cleanupDetail}`, - }); - events.end(); - return; - } - const inspection = await dockerApiJson<{ ExitCode: number }>( - "GET", - `/exec/${encodeURIComponent(created.Id)}/json`, - ); - writePtyEvent(events, { - type: "exit", - protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, - code: requestedSignal ? null : inspection.ExitCode, - signal: requestedSignal, - }); - events.end(); -} - -interface ExecutionReadiness { - ready: Promise; - close(): Promise; -} - -async function beginExecutionReadiness( - container: string, - execId: string, -): Promise { - const files = executionFiles(execId); - await docker([ - "exec", - container, - "/bin/sh", - "-c", - 'umask 077; rm -f "$1" "$2" "$3"; mkfifo "$1"', - "paseo-ready", - files.ready, - files.pid, - files.done, - ]); - const waiter = spawn( - "docker", - [ - "exec", - container, - "/bin/sh", - "-c", - 'IFS= read -r token < "$1"; status=$?; rm -f "$1"; test "$status" -eq 0 && test "$token" = "$2"', - "paseo-ready", - files.ready, - execId, - ], - { stdio: ["ignore", "ignore", "pipe"] }, - ); - const ready = Promise.all([ - readStream(waiter.stderr), - new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - waiter.once("error", reject); - waiter.once("close", (code, signal) => resolve({ code, signal })); - }), - ]).then(([stderr, exit]) => { - if (exit.code === 0) return; - throw new Error( - `Docker workload readiness failed (${exit.code ?? exit.signal}): ${stderr.trim()}`, - ); - }); - let closed = false; - return { - ready, - async close() { - if (closed) return; - closed = true; - if (waiter.exitCode === null && waiter.signalCode === null) waiter.kill("SIGKILL"); - await docker( - [ - "exec", - container, - "/bin/sh", - "-c", - 'rm -f "$1" "$2"', - "paseo-ready-cleanup", - files.ready, - files.done, - ], - true, - ); - }, - }; -} - -function executionFiles(execId: string): { pid: string; ready: string; done: string } { - const prefix = `/tmp/paseo-runtime-exec-${execId}`; - return { pid: `${prefix}.pid`, ready: `${prefix}.ready`, done: `${prefix}.done` }; -} - -async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { - let timeout: ReturnType | null = null; - try { - return await Promise.race([ - promise, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => reject(new Error(message)), timeoutMs); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } -} - -async function handlePtyControls( - workspaceId: string, - execId: string, - dockerExecId: string, - controls: AsyncIterator, - expectedOwner: string, - onSignal: (signal: NodeJS.Signals) => void, - onResize: (id: number) => void, -): Promise { - while (true) { - const next = await controls.next(); - if (next.done) return; - const control: PtyControl = CommandRuntimeControlSchema.parse(JSON.parse(next.value)); - if (control.type === "resize") { - await resizeDockerExec(dockerExecId, control.cols, control.rows); - onResize(control.id); - } else if (control.type === "signal") { - const signal = requireSignal(control.signal); - onSignal(signal); - await signalExec(workspaceId, execId, signal, expectedOwner); - } else { - throw new Error(`Unexpected PTY control: ${control.type}`); - } - } -} - -async function resizeDockerExec(execId: string, cols: number, rows: number): Promise { - if (!Number.isInteger(cols) || cols < 1 || !Number.isInteger(rows) || rows < 1) { - throw new Error("Invalid PTY size"); - } - await dockerApiJson("POST", `/exec/${encodeURIComponent(execId)}/resize?h=${rows}&w=${cols}`); -} - -function writePtyEvent(stream: Socket, event: unknown): void { - stream.write(encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event)); -} - -async function dockerApiJson>( - method: "GET" | "POST", - apiPath: string, - body?: unknown, -): Promise { - const payload = body === undefined ? "" : JSON.stringify(body); - return new Promise((resolve, reject) => { - const request = httpRequest( - { - socketPath: "/var/run/docker.sock", - method, - path: apiPath, - headers: payload - ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } - : undefined, - }, - (response) => { - void readStream(response).then((output) => { - if ((response.statusCode ?? 500) >= 300) { - return reject(new Error(`Docker API ${method} ${apiPath} failed: ${output.trim()}`)); - } - return resolve((output ? JSON.parse(output) : {}) as T); - }, reject); - }, - ); - request.once("error", reject); - request.end(payload); - }); -} - -async function startDockerExec(execId: string): Promise { - const payload = JSON.stringify({ Detach: false, Tty: true }); - return new Promise((resolve, reject) => { - const request = httpRequest({ - socketPath: "/var/run/docker.sock", - method: "POST", - path: `/exec/${encodeURIComponent(execId)}/start`, - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(payload), - Connection: "Upgrade", - Upgrade: "tcp", - }, - }); - request.once("upgrade", (_response, socket, head) => { - if (head.length > 0) socket.unshift(head); - resolve(socket); - }); - request.once("response", (response) => { - void readStream(response).then((output) => { - return reject(new Error(`Docker exec attach failed: ${output.trim()}`)); - }, reject); - }); - request.once("error", reject); - request.end(payload); - }); -} - -async function signalExec( - workspaceId: string, - execId: string, - signal: NodeJS.Signals, - expectedOwner: string, -): Promise { - if (!/^[a-f0-9]{32}$/.test(execId)) throw new Error("Invalid exec id"); - const current = await inspectPrivate(workspaceId, expectedOwner); - if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`); - const files = executionFiles(execId); - await docker([ - "exec", - resourceNames(workspaceId).container, - "/bin/sh", - "-c", - 'if [ ! -f "$1" ]; then if [ -f "$2" ]; then exit 0; fi; echo "execution identity is not ready" >&2; exit 75; fi; read -r supervisor workload < "$1" || { echo "execution identity is malformed" >&2; exit 76; }; signal=${3#SIG}; kill -"$signal" "-$workload" 2>/dev/null || kill -"$signal" "$workload" 2>/dev/null || true; tail --pid="$supervisor" --sleep-interval=.05 -f /dev/null || { echo "execution wait failed" >&2; exit 77; }; test ! -f "$1" && test -f "$2" || { echo "execution did not publish completion" >&2; exit 78; }', - "paseo-signal", - files.pid, - files.done, - signal, - ]); -} - -function resolveWorkspaceCwd(root: string, relativeCwd?: string): string { - if (!root.startsWith("/workspace") || path.posix.relative("/workspace", root).startsWith("..")) { - throw new Error(`Invalid Docker workspace root: ${root}`); - } - if (!relativeCwd) return root; - if (path.posix.isAbsolute(relativeCwd) || relativeCwd.includes("\\")) { - throw new Error("Workspace cwd must be relative"); - } - const resolved = path.posix.resolve(root, relativeCwd); - const relative = path.posix.relative(root, resolved); - if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) { - throw new Error("Workspace cwd escapes its root"); - } - return resolved; -} - -function assertContainedPath(root: string, candidate: string): void { - const relative = path.posix.relative(root, candidate); - if ( - !candidate || - relative === ".." || - relative.startsWith("../") || - path.posix.isAbsolute(relative) - ) { - throw new Error("Workspace cwd escapes its root"); - } -} - -function requireImage(options: LifecycleRequest["options"]): string { - if (typeof options.image !== "string" || !options.image.trim()) { - throw new Error("Docker runtime option image is required"); - } - return options.image; -} - -function requireOwner(runtimeInstanceId: string): string { - if (!/^[a-f0-9]{64}$/.test(runtimeInstanceId)) { - throw new Error("Runtime instance id must be a SHA-256 identity"); - } - return runtimeInstanceId; -} - -function parseDockerJsonValue(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return null; - } -} - -function assertOwnedResource( - kind: "container" | "volume", - name: string, - resource: DockerResourceLabels, - workspaceId: string, - expectedOwner: string, -): void { - if (resource.runtime !== "workspace" || resource.workspaceId !== workspaceId) { - throw new Error(`Docker ${kind} ownership mismatch: ${name}`); - } - if (resource.runtimeOwner !== expectedOwner) { - throw new Error(`Docker ${kind} runtime owner mismatch: ${name}`); - } -} - -async function cleanupOwnedCreation(workspaceId: string, expectedOwner: string): Promise { - const names = resourceNames(workspaceId); - const errors: Error[] = []; - const [container, volume] = await Promise.all([ - inspectContainer(names.container), - inspectVolume(names.volume), - ]); - const remove = async ( - kind: "container" | "volume", - resource: DockerResourceLabels | null, - args: string[], - ): Promise => { - if (!resource) return; - try { - assertOwnedResource(kind, names[kind], resource, workspaceId, expectedOwner); - await docker(args); - } catch (error) { - errors.push(error instanceof Error ? error : new Error(String(error))); - } - }; - await remove("container", container, ["rm", "-f", names.container]); - await remove("volume", volume, ["volume", "rm", "-f", names.volume]); - return errors; -} - -function requireBindMounts(options: LifecycleRequest["options"]): readonly DockerBindMount[] { - const value = options.bindMounts; - if (value === undefined) return []; - if (!Array.isArray(value)) throw new Error("Docker runtime option bindMounts must be an array"); - return value.map((entry, index) => { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - throw new Error(`Docker bind mount ${index} must be an object`); - } - const { source, target, readOnly } = entry as Record; - if (typeof source !== "string" || !path.isAbsolute(source)) { - throw new Error(`Docker bind mount ${index} source must be absolute`); - } - if (typeof target !== "string" || !path.posix.isAbsolute(target)) { - throw new Error(`Docker bind mount ${index} target must be an absolute container path`); - } - const normalizedTarget = path.posix.normalize(target); - const workspaceRelative = path.posix.relative("/workspace", normalizedTarget); - if (workspaceRelative === "" || !workspaceRelative.startsWith("../")) { - throw new Error(`Docker bind mount ${index} target must be outside /workspace`); - } - if (typeof readOnly !== "boolean") { - throw new Error(`Docker bind mount ${index} readOnly must be boolean`); - } - return { source, target: normalizedTarget, readOnly }; - }); -} - -function dockerBindMountArguments(bindMounts: readonly DockerBindMount[]): string[] { - return bindMounts.flatMap(({ source, target, readOnly }) => [ - "--mount", - `type=bind,source=${source},target=${target}${readOnly ? ",readonly" : ""}`, - ]); -} - -function resourceNames(workspaceId: string): { container: string; volume: string } { - const key = createHash("sha256").update(workspaceId).digest("hex").slice(0, 20); - return { container: `paseo-ws-${key}`, volume: `paseo-ws-${key}` }; -} - -function stateFor( - workspaceId: string, - root: string, - revision: string, - container: string, - lifecycle: "ready" | "paused" = "ready", -): PrivateRuntimeState { - return { - workspaceId, - root, - revision, - container, - lifecycle, - lifecycleEnvironment: { - PASEO_SOURCE_CHECKOUT_PATH: ".", - PASEO_ROOT_PATH: ".", - PASEO_WORKTREE_PATH: ".", - PASEO_BRANCH_NAME: "", - }, - }; -} - -function publicState(state: PrivateRuntimeState): RuntimeState { - return { - workspaceId: state.workspaceId, - lifecycle: state.lifecycle, - lifecycleEnvironment: state.lifecycleEnvironment, - }; -} - -function runtimePlacement(state: PrivateRuntimeState): { cwd: string } { - return { cwd: state.root }; -} - -async function waitUntilReady(container: string): Promise { - let lastError = ""; - for (let attempt = 0; attempt < 50; attempt += 1) { - const output = await docker( - ["exec", container, "/bin/sh", "-c", "test -d /workspace && echo ready"], - true, - ); - if (output.trim() === "ready") return; - lastError = `Container is not ready: ${container}`; - await new Promise((resolve) => setTimeout(resolve, 100)); - } - throw new Error(lastError); -} - -async function docker(args: string[], allowFailure = false): Promise { - const child = spawn("docker", args, { stdio: ["ignore", "pipe", "pipe"] }); - const [stdout, stderr, exit] = await Promise.all([ - readStream(child.stdout), - readStream(child.stderr), - new Promise<{ code: number | null }>((resolve, reject) => { - child.once("error", reject); - child.once("close", (code) => resolve({ code })); - }), - ]); - if (exit.code !== 0) { - if (allowFailure) return ""; - throw new Error(`docker ${args[0]} failed: ${stderr.trim()}`); - } - return stdout; -} - -async function readStdin(): Promise { - return readStream(process.stdin); -} - -async function readStream(stream: NodeJS.ReadableStream): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - return Buffer.concat(chunks).toString("utf8"); -} - -function writeJson(schema: CommandRuntimeMessageSchema, value: unknown): void { - process.stdout.write(encodeCommandRuntimeMessage(schema, value)); -} - -function argument(flag: string): string | undefined { - const index = process.argv.indexOf(flag, 2); - return index < 0 ? undefined : process.argv[index + 1]; -} - -function requireArgument(flag: string): string { - const value = argument(flag); - if (!value) throw new Error(`${flag} is required`); - return value; -} - -function requireSignal(value: string): NodeJS.Signals { - if (value !== "SIGINT" && value !== "SIGTERM" && value !== "SIGHUP" && value !== "SIGKILL") { - throw new Error(`Unsupported signal: ${value}`); - } - return value; -} diff --git a/packages/docker-workspace-runtime/src/workload.mjs b/packages/docker-workspace-runtime/src/workload.mjs deleted file mode 100644 index 0abacde771..0000000000 --- a/packages/docker-workspace-runtime/src/workload.mjs +++ /dev/null @@ -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; -}); diff --git a/packages/fixture-workspace-runtime/package.json b/packages/fixture-workspace-runtime/package.json deleted file mode 100644 index fc25e171b2..0000000000 --- a/packages/fixture-workspace-runtime/package.json +++ /dev/null @@ -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" - } -} diff --git a/packages/server/package.json b/packages/server/package.json index 4f347d2a5c..97afa0b88f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts index 966413ba7c..0f253dd584 100644 --- a/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts +++ b/packages/server/src/server/agent/providers/acp-workspace-runtime.posix.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/providers/workspace/index.ts b/packages/server/src/server/agent/providers/workspace/index.ts index 694937f47f..14f7755018 100644 --- a/packages/server/src/server/agent/providers/workspace/index.ts +++ b/packages/server/src/server/agent/providers/workspace/index.ts @@ -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: diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2e03f763f1..a8214ed98b 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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, diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index 1824363b7f..d1315eeaa4 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -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( diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts index 647e2eddad..5dac8c0498 100644 --- a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -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( + selectedClient: DaemonClient, + predicate: (message: SessionOutboundMessage) => message is T, +): Promise { + 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 => + 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 => + 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; + 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 }, }, }, diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index 780450c701..98c95e951f 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -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 diff --git a/packages/server/src/server/provider-probe/internal/service.ts b/packages/server/src/server/provider-probe/internal/service.ts index 301d686a90..2e79b8ac28 100644 --- a/packages/server/src/server/provider-probe/internal/service.ts +++ b/packages/server/src/server/provider-probe/internal/service.ts @@ -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}`); diff --git a/packages/server/src/server/provider-probe/provider-probe.posix.test.ts b/packages/server/src/server/provider-probe/provider-probe.posix.test.ts index 1b6a3f454d..610da84583 100644 --- a/packages/server/src/server/provider-probe/provider-probe.posix.test.ts +++ b/packages/server/src/server/provider-probe/provider-probe.posix.test.ts @@ -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", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f36ac8d03a..6ef074d361 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 6f874158be..63c815a765 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -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({ diff --git a/packages/server/src/server/session/files/workspace-files-session.ts b/packages/server/src/server/session/files/workspace-files-session.ts index f6b1f4acce..452a8926ca 100644 --- a/packages/server/src/server/session/files/workspace-files-session.ts +++ b/packages/server/src/server/session/files/workspace-files-session.ts @@ -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"; diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index a56303fd64..6d0bc3f09e 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -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 }, diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index 22bf54826a..db6032e194 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -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, diff --git a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts index db2ccd87e4..565ce176b3 100644 --- a/packages/server/src/server/workspace-git-service.runtime.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.runtime.integration.test.ts @@ -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 }, }, }, diff --git a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts index a836c16ef6..31a92bf9b7 100644 --- a/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts +++ b/packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts @@ -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, diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index 855f808ada..2a56d3d1b7 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -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>; create(input: WorkspaceDriverCreateInput): Promise; inspect(workspaceId: WorkspaceId): Promise; spawn(input: WorkspaceDriverSpawnInput): Promise; diff --git a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts index 30e4c654ce..621a2d9d16 100644 --- a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts @@ -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 { diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 31d4770e37..46280de574 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -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; requireHostVisiblePath(workspaceId: string): Promise; - runSetup(workspaceId: string, signal?: AbortSignal): Promise; pause(workspaceId: string): Promise; resume(workspaceId: string): Promise; archive(workspaceId: string): Promise; @@ -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); + | ({ status: "paused" | "ready" } & Omit< + WorkspaceRuntimePlacement, + "workspaceId" | "runtimeId" | "materializedFreshContent" + >); export interface WorkspaceRuntimeRecordStore { resolveRuntimeId(workspaceId: string): Promise; diff --git a/packages/server/src/server/workspace-runtime/internal/host-helper.ts b/packages/server/src/server/workspace-runtime/internal/host-helper.ts index ec299de3cb..04d8ea8428 100644 --- a/packages/server/src/server/workspace-runtime/internal/host-helper.ts +++ b/packages/server/src/server/workspace-runtime/internal/host-helper.ts @@ -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[]], diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index 9f61496e90..b0e6d5597e 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -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(); const boundRuntimes = new Map(); + const setupAugmentations = new Map>>(); 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 { - 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 { const chunks: Buffer[] = []; let bytes = 0; diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts index 4ca50215da..36c77a3765 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -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, diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts index 9441954758..d71779bde0 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime-boundary.test.ts @@ -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; + files?: string[]; scripts?: Record; } { return JSON.parse(readFileSync(path.join(repositoryRoot, relativePath), "utf8")); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts index f3174bb3a3..5474656445 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.command.test.ts @@ -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, diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts deleted file mode 100644 index f76b672b72..0000000000 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts +++ /dev/null @@ -1,2112 +0,0 @@ -import { execFile, execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { existsSync, watch } from "node:fs"; -import { - chmod, - copyFile, - mkdir, - mkdtemp, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; - -import { expect, onTestFinished, test, vi } from "vitest"; - -import { createWorkspaceRuntimeService } from "./index.js"; -import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { DaemonClient } from "../test-utils/daemon-client.js"; -import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; -import { allocateWorkspaceServicePort } from "../workspace-service-port-allocator.js"; -import { - createPersistedProjectRecord, - createPersistedWorkspaceRecord, - FileBackedProjectRegistry, - FileBackedWorkspaceRegistry, -} from "../workspace-registry.js"; - -const repositoryRoot = fileURLToPath(new URL("../../../../..", import.meta.url)); -const dockerRuntimeRoot = path.join(repositoryRoot, "packages/docker-workspace-runtime"); -const runtimeExecutable = path.join(dockerRuntimeRoot, "dist/index.js"); -const image = process.env.E2E_DOCKER_WORKSPACE_IMAGE ?? "paseo-workspace-runtime:phase3-qa"; -const fixtureAgent = fileURLToPath( - new URL("../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url), -); -const run = promisify(execFile); - -function dockerRegistration(options: Readonly> = {}) { - return { - type: "command" as const, - label: "Docker", - command: ["@getpaseo/docker-workspace-runtime"] as const, - options: { image, ...options }, - }; -} - -async function collect(stream: NodeJS.ReadableStream): Promise { - const chunks: Buffer[] = []; - for await (const chunk of stream) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks).toString("utf8"); -} - -test("the command runtime owns Docker workspace materialization, pipes, and lifecycle", async () => { - const originalHostOnlySetup = process.env.PASEO_SETUP_CUSTOM_INHERITED; - process.env.PASEO_SETUP_CUSTOM_INHERITED = "must-not-enter-container"; - const fixture = await dockerTestFixture("paseo-runtime-docker-"); - const root = fixture.root; - const repo = await createRepository(root); - await writeFile(path.join(repo, ".env"), "must-not-enter-runtime\n"); - const runtimeIds = new Map(); - const options = { - paseoHome: path.join(root, "paseo-home"), - resolveRuntimeId: async (workspaceId: string) => runtimeIds.get(workspaceId) ?? null, - persistRuntimeId: async (workspaceId: string, runtimeId: string) => { - runtimeIds.set(workspaceId, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (workspaceId: string) => { - runtimeIds.delete(workspaceId); - }, - externalRuntimes: { - docker: dockerRegistration(), - }, - }; - const service = createWorkspaceRuntimeService(options); - const workspaceId = `docker-${Date.now()}`; - fixture.workspace(workspaceId); - - try { - const workspace = await service.create({ - workspaceId, - runtimeId: "docker", - project: { - id: "docker-project", - source: { kind: "host-directory", path: repo }, - }, - placement: { kind: "existing" }, - }); - - expect(workspace).toEqual({ - workspaceId, - runtimeId: "docker", - cwd: "/workspace", - materializedFreshContent: true, - }); - await expect(service.inspect(workspaceId)).resolves.toEqual({ - status: "ready", - cwd: "/workspace", - }); - await expect(service.requireHostVisiblePath(workspaceId)).rejects.toThrow( - `Workspace has no host-visible path: ${workspaceId}`, - ); - - const setupEnvironment = await service.run({ - workspaceId, - argv: [ - "/usr/local/bin/node", - "-e", - "process.stdout.write(JSON.stringify({source:process.env.PASEO_SOURCE_CHECKOUT_PATH,root:process.env.PASEO_ROOT_PATH,worktree:process.env.PASEO_WORKTREE_PATH,branch:process.env.PASEO_BRANCH_NAME,port:process.env.PASEO_WORKTREE_PORT,path:process.env.PATH,hostOnly:process.env.PASEO_SETUP_CUSTOM_INHERITED}))", - ], - env: {}, - purpose: { kind: "setup" }, - }); - setupEnvironment.stdin.end(); - await expect(collect(setupEnvironment.stdout).then(JSON.parse)).resolves.toEqual({ - source: ".", - root: ".", - worktree: ".", - branch: "", - port: expect.stringMatching(/^\d+$/u), - path: "/usr/local/bin:/usr/bin:/bin", - }); - await expect(setupEnvironment.exited).resolves.toEqual({ code: 0, signal: null }); - - for (const [name, value] of [ - ["user.email", "test@example.com"], - ["user.name", "Paseo Test"], - ] as const) { - const configure = await service.run({ - workspaceId, - argv: ["git", "config", name, value], - env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, - purpose: { kind: "git" }, - }); - configure.stdin.end(); - await expect(configure.exited).resolves.toEqual({ code: 0, signal: null }); - } - - const workspaceGitService = new WorkspaceGitServiceImpl({ - logger: createLogger(), - paseoHome: options.paseoHome, - workspaceRuntime: service, - }); - const workspaceGit = workspaceGitService.bindWorkspace({ workspaceId, cwd: repo }); - - const files = service.files(workspaceId); - const listing = await files.list("."); - expect(listing.entries.map((entry) => entry.name)).toContain("committed.txt"); - const initialFile = await files.stat("committed.txt"); - expect(initialFile).toMatchObject({ status: "ready", size: 10 }); - let resolveFileChange!: () => void; - const fileChanged = new Promise((resolve) => { - resolveFileChange = resolve; - }); - const fileSubscription = await files.subscribe({ paths: ["committed.txt"] }, (event) => { - if (event.type === "changed" && event.paths.includes("committed.txt")) resolveFileChange(); - }); - const terminalFileEdit = await service.run({ - workspaceId, - argv: ["/bin/sh", "-c", "printf container-change > committed.txt"], - env: {}, - purpose: { kind: "terminal", terminalId: "docker-file-edit" }, - }); - terminalFileEdit.stdin.end(); - await expect(terminalFileEdit.exited).resolves.toEqual({ code: 0, signal: null }); - await expect(fileChanged).resolves.toBeUndefined(); - const dirtyGit = await workspaceGit.getSnapshot({ - force: true, - includeForge: false, - reason: "docker-edit", - }); - const dockerDiff = await workspaceGit.getCheckoutDiff( - { mode: "uncommitted", includeStructured: true }, - { force: true, reason: "docker-edit" }, - ); - expect(dirtyGit.git).toMatchObject({ isGit: true, isDirty: true, currentBranch: "main" }); - expect(JSON.stringify(dockerDiff)).toContain("container-change"); - await workspaceGit.commit({ message: "container commit", addAll: true }); - await workspaceGit.createBranch({ branch: "container-branch", baseRef: "main" }); - await workspaceGit.switchBranch("main"); - await workspaceGit.fetch(); - const cleanGit = await workspaceGit.getSnapshot(); - expect(cleanGit.git).toMatchObject({ isDirty: false, currentBranch: "main" }); - expect(await readFileFromHost(repo, "committed.txt")).toBe("committed\n"); - const containerFile = await files.read("committed.txt"); - await expect(collect(containerFile.chunks)).resolves.toBe("container-change"); - const conflict = await files.write({ - path: "committed.txt", - contents: Buffer.from("stale"), - ...(initialFile.status === "ready" ? { expectedRevision: initialFile.revision } : {}), - }); - expect(conflict).toMatchObject({ status: "conflict" }); - const large = await files.read("large.bin"); - expect(await collectBytes(large.chunks)).toEqual(Buffer.alloc(700_000, 0xa5)); - await expect(files.read("outside-link")).rejects.toThrow( - "Access outside of workspace is not allowed", - ); - await fileSubscription.unsubscribe(); - - const write = await service.run({ - workspaceId, - argv: ["/bin/sh", "-c", "cat > runtime-owned.txt; printf runtime-stderr >&2; exit 19"], - env: {}, - purpose: { kind: "workspace-script", script: "write-contract" }, - }); - write.stdin.end("docker-state"); - const [stderr, writeExit] = await Promise.all([collect(write.stderr), write.exited]); - expect(stderr).toBe("runtime-stderr"); - expect(writeExit).toEqual({ code: 19, signal: null }); - - const env = await service.run({ - workspaceId, - argv: ["/usr/bin/env"], - env: { RUNTIME_EXACT_ENV: "docker" }, - purpose: { kind: "workspace-script", script: "environment-contract" }, - }); - env.stdin.end(); - await expect(collect(env.stdout)).resolves.toBe("RUNTIME_EXACT_ENV=docker\n"); - await expect(env.exited).resolves.toEqual({ code: 0, signal: null }); - - await service.pause(workspaceId); - await expect( - service.run({ - workspaceId, - argv: ["/bin/true"], - env: {}, - purpose: { kind: "workspace-script", script: "paused-contract" }, - }), - ).rejects.toThrow(`Workspace runtime is paused: ${workspaceId}`); - - const recovered = createWorkspaceRuntimeService(options); - await recovered.resume(workspaceId); - const escapedCwd = await recovered.run({ - workspaceId, - cwd: "../escape", - argv: ["/bin/true"], - env: {}, - purpose: { kind: "workspace-script", script: "cwd-contract" }, - }); - escapedCwd.stdin.end(); - await expect(escapedCwd.exited).rejects.toThrow("Workspace cwd escapes its root"); - const read = await recovered.run({ - workspaceId, - argv: ["/bin/cat", "runtime-owned.txt"], - env: {}, - purpose: { kind: "workspace-script", script: "read-contract" }, - }); - read.stdin.end(); - await expect(collect(read.stdout)).resolves.toBe("docker-state"); - await expect(read.exited).resolves.toEqual({ code: 0, signal: null }); - - const terminal = await recovered.openTerminal({ - workspaceId, - argv: [ - "/usr/local/bin/node", - "-e", - "process.stdin.setEncoding('utf8');process.stdout.write(`${process.cwd()}|${process.stdout.isTTY}|${process.stdout.columns}x${process.stdout.rows}|λ`);process.stdin.once('data',data=>{process.stdout.write(`|${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(6)})", - ], - env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, - purpose: { kind: "terminal", terminalId: "docker-contract-terminal" }, - rows: 24, - cols: 80, - }); - let terminalOutput = ""; - terminal.onData((data) => { - terminalOutput += data; - }); - await Promise.race([ - vi.waitFor(() => expect(terminalOutput).toContain("/workspace|true|80x24|λ"), { - timeout: 10_000, - }), - terminal.exited.then((exit) => { - throw new Error(`Docker PTY exited before initial output: ${JSON.stringify(exit)}`); - }), - ]); - terminal.resize(103, 39); - terminal.write("héllo\n"); - await expect(terminal.exited).resolves.toEqual({ code: 6, signal: null }); - expect(terminalOutput).toContain("|héllo|103x39"); - - const terminalSignal = await recovered.openTerminal({ - workspaceId, - argv: ["/bin/sleep", "30"], - env: { PATH: "/usr/bin:/bin" }, - purpose: { kind: "terminal", terminalId: "docker-signal-terminal" }, - rows: 24, - cols: 80, - }); - terminalSignal.kill("SIGTERM"); - await expect(terminalSignal.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); - await vi.waitFor(() => expect(dockerProcesses(workspaceId)).not.toContain("sleep 30")); - - const terminalForced = await recovered.openTerminal({ - workspaceId, - argv: ["/bin/sleep", "30"], - env: { PATH: "/usr/bin:/bin" }, - purpose: { kind: "terminal", terminalId: "docker-force-terminal" }, - rows: 24, - cols: 80, - }); - terminalForced.kill("SIGKILL"); - await expect(terminalForced.exited).resolves.toEqual({ code: null, signal: "SIGKILL" }); - await vi.waitFor(() => expect(dockerProcesses(workspaceId)).not.toContain("sleep 30")); - - const terminalControlFailure = await recovered.openTerminal({ - workspaceId, - argv: ["/bin/sleep", "30"], - env: { PATH: "/usr/bin:/bin" }, - purpose: { kind: "terminal", terminalId: "docker-control-failure-terminal" }, - rows: 24, - cols: 80, - }); - terminalControlFailure.kill("SIGUSR1"); - await expect(terminalControlFailure.exited).rejects.toThrow("Unsupported signal: SIGUSR1"); - expect(dockerProcesses(workspaceId)).not.toContain("sleep 30"); - - const committedOnly = await recovered.run({ - workspaceId, - argv: ["/bin/sh", "-c", "test ! -e .env"], - env: {}, - purpose: { kind: "workspace-script", script: "materialization-contract" }, - }); - committedOnly.stdin.end(); - await expect(committedOnly.exited).resolves.toEqual({ code: 0, signal: null }); - expect(execFileSync("git", ["status", "--short"], { cwd: repo, encoding: "utf8" })).toBe( - "?? .env\n", - ); - - const killed = await recovered.run({ - workspaceId, - argv: ["/bin/sh", "-c", "sleep 30 & echo ready; wait"], - env: { PATH: "/usr/bin:/bin" }, - purpose: { kind: "workspace-script", script: "signal-contract" }, - }); - killed.stdin.end(); - await expect(waitForOutput(killed.stdout, "ready")).resolves.toContain("ready"); - const killedAt = Date.now(); - killed.kill("SIGTERM"); - await expect(killed.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); - expect(Date.now() - killedAt).toBeLessThan(5_000); - await vi.waitFor(() => expect(dockerProcesses(workspaceId)).not.toContain("sleep 30")); - - const stubborn = await recovered.run({ - workspaceId, - argv: [ - "/usr/local/bin/node", - "-e", - "process.on('SIGTERM',()=>{});console.log('stubborn-ready');setInterval(()=>{},1000)", - ], - env: {}, - purpose: { kind: "workspace-script", script: "escalation-contract" }, - }); - stubborn.stdin.end(); - await expect(waitForOutput(stubborn.stdout, "stubborn-ready")).resolves.toContain( - "stubborn-ready", - ); - const pauseStartedAt = Date.now(); - await recovered.pause(workspaceId); - expect(Date.now() - pauseStartedAt).toBeLessThan(5_000); - await expect(stubborn.exited).resolves.toEqual({ code: null, signal: "SIGKILL" }); - expect( - execFileSync( - "docker", - ["inspect", "--format", "{{.State.Running}}", dockerResourceName(workspaceId)], - { - encoding: "utf8", - }, - ).trim(), - ).toBe("false"); - - await workspaceGitService.dispose(); - await recovered.destroy(workspaceId); - expect(dockerResourceCount("ps", workspaceId)).toBe(0); - expect(dockerResourceCount("volume", workspaceId)).toBe(0); - expect(await readFileFromHost(repo, "runtime-owned.txt")).toBeNull(); - await expect( - recovered.run({ - workspaceId, - argv: ["/bin/true"], - env: {}, - purpose: { kind: "workspace-script", script: "missing-contract" }, - }), - ).rejects.toThrow(`Workspace runtime is not selected: ${workspaceId}`); - } finally { - if (originalHostOnlySetup === undefined) delete process.env.PASEO_SETUP_CUSTOM_INHERITED; - else process.env.PASEO_SETUP_CUSTOM_INHERITED = originalHostOnlySetup; - await service.destroy(workspaceId).catch(() => undefined); - } -}, 180_000); - -test("production archive and deletion reap Docker runtime processes and owned resources", async () => { - const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-lifecycle-cleanup-")); - const repo = await createRepository(root); - const paseoHome = path.join(root, "paseo-home"); - const workspaceId = `docker-lifecycle-cleanup-${Date.now()}`; - const runtimeIds = new Map(); - const archivedWorkspaceIds = new Set(); - const service = createWorkspaceRuntimeService({ - paseoHome, - resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, - persistRuntimeId: async (id, runtimeId) => void runtimeIds.set(id, runtimeId), - beginWorkspaceDeletion: async () => {}, - archiveWorkspaceRecord: async (id) => void archivedWorkspaceIds.add(id), - restoreWorkspaceRecord: async (id) => void archivedWorkspaceIds.delete(id), - removeWorkspaceRecord: async (id) => { - runtimeIds.delete(id); - archivedWorkspaceIds.delete(id); - }, - externalRuntimes: { docker: dockerRegistration() }, - }); - - onTestFinished(async () => { - await service.destroy(workspaceId).catch(() => undefined); - const leakedContainers = dockerResourceCount("ps", workspaceId); - const leakedVolumes = dockerResourceCount("volume", workspaceId); - const leakedProcesses = dockerTestProcessPids(new Set([workspaceId])); - await rm(root, { recursive: true, force: true }); - if (leakedContainers || leakedVolumes || leakedProcesses.length) { - throw new Error( - `Production lifecycle leaked containers=${leakedContainers} volumes=${leakedVolumes} processes=${leakedProcesses.join(",")}`, - ); - } - }, 30_000); - - await service.create({ - workspaceId, - runtimeId: "docker", - project: { - id: "docker-lifecycle-cleanup-project", - source: { kind: "host-directory", path: repo }, - }, - placement: { kind: "existing" }, - purpose: "provider-probe", - }); - const runtimeProcess = await service.run({ - workspaceId, - argv: [ - "/usr/local/bin/node", - "-e", - "process.on('SIGTERM',()=>{});console.log('owned-ready');setInterval(()=>{},1000)", - ], - env: {}, - purpose: { kind: "provider-probe", provider: "codex" }, - }); - runtimeProcess.stdin.end(); - await expect(waitForOutput(runtimeProcess.stdout, "owned-ready")).resolves.toContain( - "owned-ready", - ); - - await service.archive(workspaceId); - expect(archivedWorkspaceIds).toContain(workspaceId); - await expect(runtimeProcess.exited).resolves.toEqual({ code: null, signal: "SIGKILL" }); - expect(dockerRunningContainerCount(workspaceId)).toBe(0); - expect(dockerResourceCount("ps", workspaceId)).toBe(1); - expect(dockerResourceCount("volume", workspaceId)).toBe(1); - expect(dockerTestProcessPids(new Set([workspaceId]))).toEqual([]); - - await service.destroy(workspaceId); - await service.destroy(workspaceId); - expect(runtimeIds.has(workspaceId)).toBe(false); - expect(archivedWorkspaceIds.has(workspaceId)).toBe(false); - expect(dockerResourceCount("ps", workspaceId)).toBe(0); - expect(dockerResourceCount("volume", workspaceId)).toBe(0); - expect(dockerTestProcessPids(new Set([workspaceId]))).toEqual([]); -}, 60_000); - -test("Docker buffers immediate pipe signals until the workload identity is authoritative", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-started-"); - const root = fixture.root; - const repo = await createRepository(root); - const workspaceId = `docker-started-${Date.now()}`; - fixture.workspace(workspaceId); - const runtimeIds = new Map(); - const service = createWorkspaceRuntimeService({ - paseoHome: path.join(root, "paseo-home"), - resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, - persistRuntimeId: async (id, runtimeId) => { - runtimeIds.set(id, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (id) => { - runtimeIds.delete(id); - }, - externalRuntimes: { - docker: dockerRegistration(), - }, - }); - const originalPath = process.env.PATH; - const originalBarrier = process.env.PASEO_DOCKER_TEST_BARRIER; - - try { - await service.create({ - workspaceId, - runtimeId: "docker", - project: { id: "started-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }); - const shimDirectory = path.join(root, "docker-shim"); - await mkdir(shimDirectory); - const realDocker = execFileSync("which", ["docker"], { encoding: "utf8" }).trim(); - const shim = path.join(shimDirectory, "docker"); - await writeFile(shim, dockerBarrierShim(realDocker)); - await chmod(shim, 0o755); - process.env.PATH = `${shimDirectory}:${originalPath ?? ""}`; - - for (let attempt = 0; attempt < 6; attempt += 1) { - const barrier = path.join(root, `barrier-${attempt}`); - await mkdir(barrier); - process.env.PASEO_DOCKER_TEST_BARRIER = barrier; - const entered = nextFile(barrier, "entered"); - const workload = await service.run({ - workspaceId, - argv: ["/bin/sleep", "3"], - env: { PATH: "/usr/bin:/bin" }, - purpose: { kind: "workspace-script", script: `immediate-signal-${attempt}` }, - }); - workload.stdin.end(); - await entered; - const killedAt = Date.now(); - workload.kill("SIGTERM"); - await writeFile(path.join(barrier, "release"), "release"); - - await expect(workload.exited).resolves.toEqual({ code: null, signal: "SIGTERM" }); - expect(Date.now() - killedAt).toBeLessThan(1_500); - expect(dockerProcesses(workspaceId)).not.toContain("sleep 3"); - } - - const shortBarrier = path.join(root, "barrier-short-exit"); - await mkdir(shortBarrier); - process.env.PASEO_DOCKER_TEST_BARRIER = shortBarrier; - const shortEntered = nextFile(shortBarrier, "entered"); - const short = await service.run({ - workspaceId, - argv: ["/bin/true"], - env: {}, - purpose: { kind: "workspace-script", script: "exit-around-started" }, - }); - short.stdin.end(); - await shortEntered; - await writeFile(path.join(shortBarrier, "release"), "release"); - await expect(short.exited).resolves.toEqual({ code: 0, signal: null }); - - delete process.env.PASEO_DOCKER_TEST_BARRIER; - const later = await service.run({ - workspaceId, - argv: ["/bin/true"], - env: {}, - purpose: { kind: "workspace-script", script: "after-immediate-signals" }, - }); - later.stdin.end(); - await expect(later.exited).resolves.toEqual({ code: 0, signal: null }); - } finally { - process.env.PATH = originalPath; - if (originalBarrier === undefined) delete process.env.PASEO_DOCKER_TEST_BARRIER; - else process.env.PASEO_DOCKER_TEST_BARRIER = originalBarrier; - await service.destroy(workspaceId).catch(() => undefined); - } -}, 120_000); - -test("a selected Docker service port script executes in the container and leaves the host decoy untouched", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-port-script-"); - const root = fixture.root; - const repo = await createRepository(root); - const script = path.join(repo, "port-script.sh"); - await writeFile( - script, - "#!/bin/sh\nprintf runtime > runtime-port-script.txt\nprintf '41234\\n'\n", - ); - await chmod(script, 0o755); - execFileSync("git", ["add", "port-script.sh"], { cwd: repo }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "port script"], { - cwd: repo, - }); - const workspaceId = `docker-port-script-${Date.now()}`; - fixture.workspace(workspaceId); - const runtimeIds = new Map(); - const service = createWorkspaceRuntimeService({ - paseoHome: path.join(root, "paseo-home"), - resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, - persistRuntimeId: async (id, runtimeId) => { - runtimeIds.set(id, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (id) => { - runtimeIds.delete(id); - }, - externalRuntimes: { - docker: dockerRegistration(), - }, - }); - try { - await service.create({ - workspaceId, - runtimeId: "docker", - project: { id: "docker-port-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }); - await writeFile(script, "#!/bin/sh\nprintf host > host-port-script.txt\nprintf '49999\\n'\n"); - - await expect( - allocateWorkspaceServicePort({ - allocation: { portScript: "./port-script.sh" }, - cwd: repo, - scriptName: "web", - workspaceId, - branchName: null, - runtime: await service.bind(workspaceId), - }), - ).resolves.toBe(41234); - await expect(service.files(workspaceId).stat("runtime-port-script.txt")).resolves.toMatchObject( - { - status: "ready", - }, - ); - await expect(readFile(path.join(repo, "host-port-script.txt"), "utf8")).rejects.toMatchObject({ - code: "ENOENT", - }); - } finally { - await service.destroy(workspaceId).catch(() => undefined); - } -}, 120_000); - -test("public daemon and client keep Docker provider discovery, launch, files, Git, and snapshots in one runtime", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-daemon-"); - const root = fixture.root; - const repo = await createRepository(root); - await writeFile(path.join(repo, "provider-model.txt"), "fixture-model-v1\n"); - execFileSync("git", ["add", "provider-model.txt"], { cwd: repo }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "provider model"], { - cwd: repo, - }); - const paseoHomeRoot = path.join(root, "daemon-home"); - const paseoHome = path.join(paseoHomeRoot, ".paseo"); - await mkdir(paseoHome, { recursive: true }); - const runtimeConfig = dockerRegistration(); - const runtimeConfigs = { docker: runtimeConfig }; - const workspaceIds = [`docker-daemon-a-${Date.now()}`, `docker-daemon-b-${Date.now()}`]; - for (const workspaceId of workspaceIds) fixture.workspace(workspaceId); - const hostProbeMarker = path.join(root, "host-provider-probed.txt"); - const hostOnlyProvider = path.join(root, "host-only-provider.mjs"); - await writeFile( - hostOnlyProvider, - `#!/usr/bin/env node\nawait import("node:fs/promises").then((fs) => fs.appendFile(${JSON.stringify(hostProbeMarker)}, \`${"${process.cwd()}"}\\n\`));\n${(await readFile(fixtureAgent, "utf8")).replace(/^#![^\n]*\n/u, "")}`, - ); - await chmod(hostOnlyProvider, 0o755); - const selectedRuntimeIds = new Map([ - [workspaceIds[0]!, "docker"], - [workspaceIds[1]!, "docker"], - ]); - const seedRuntime = createWorkspaceRuntimeService({ - paseoHome, - externalRuntimes: runtimeConfigs, - resolveRuntimeId: async (workspaceId) => selectedRuntimeIds.get(workspaceId) ?? null, - persistRuntimeId: async (workspaceId, runtimeId) => { - selectedRuntimeIds.set(workspaceId, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (workspaceId) => { - selectedRuntimeIds.delete(workspaceId); - }, - }); - const projectRegistry = new FileBackedProjectRegistry( - path.join(paseoHome, "projects", "projects.json"), - createTestLogger(), - ); - const workspaceRegistry = new FileBackedWorkspaceRegistry( - path.join(paseoHome, "projects", "workspaces.json"), - createTestLogger(), - ); - await projectRegistry.initialize(); - await workspaceRegistry.initialize(); - const now = new Date().toISOString(); - await projectRegistry.upsert( - createPersistedProjectRecord({ - projectId: "docker-daemon-project", - rootPath: repo, - kind: "git", - displayName: "docker-daemon-project", - createdAt: now, - updatedAt: now, - }), - ); - for (const workspaceId of workspaceIds) { - const runtimeId = selectedRuntimeIds.get(workspaceId)!; - const created = await seedRuntime.create({ - workspaceId, - runtimeId, - project: { id: "docker-daemon-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }); - await workspaceRegistry.upsert( - createPersistedWorkspaceRecord({ - workspaceId, - projectId: "docker-daemon-project", - cwd: created.cwd, - hostVisiblePath: created.hostVisiblePath ?? null, - kind: "local_checkout", - displayName: workspaceId, - runtime: { runtimeId }, - createdAt: now, - updatedAt: now, - }), - ); - } - - const daemon = await createTestPaseoDaemon({ - paseoHomeRoot, - cleanup: false, - mcpEnabled: false, - agentClients: {}, - workspaceRuntimes: runtimeConfigs, - providerOverrides: { - "docker-fixture": { - extends: "acp", - label: "Docker Fixture", - command: ["node", "./fixture-agent.mjs"], - params: { supportsMcpServers: false }, - enabled: true, - }, - "host-only-fixture": { - extends: "acp", - label: "Host Only", - command: [hostOnlyProvider], - params: { supportsMcpServers: false }, - enabled: true, - }, - }, - }); - const publicClient = new DaemonClient({ - url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.3.0-beta.2", - reconnect: { enabled: false }, - }); - - try { - await publicClient.connect(); - await publicClient.fetchAgents({ subscribe: { subscriptionId: "docker-runtime-agents" } }); - const recoveredRegistry = new FileBackedWorkspaceRegistry( - path.join(paseoHome, "projects", "workspaces.json"), - createTestLogger(), - ); - await recoveredRegistry.initialize(); - const recoveredRecords = await recoveredRegistry.list(); - const publicCwds = new Map( - recoveredRecords - .filter((workspace) => workspaceIds.includes(workspace.workspaceId)) - .map((workspace) => [workspace.workspaceId, workspace.cwd]), - ); - expect(Array.from(publicCwds.values())).toEqual(["/workspace", "/workspace"]); - expect( - recoveredRecords - .filter((workspace) => workspaceIds.includes(workspace.workspaceId)) - .map((workspace) => workspace.hostVisiblePath), - ).toEqual([null, null]); - for (const workspaceId of workspaceIds) { - const cwd = publicCwds.get(workspaceId)!; - const snapshot = await publicClient.getProvidersSnapshot({ cwd, workspaceId }); - expect(snapshot.entries).toContainEqual( - expect.objectContaining({ - provider: "docker-fixture", - status: "ready", - models: [expect.objectContaining({ id: "fixture-model-v1" })], - }), - ); - expect(snapshot.entries).toContainEqual( - expect.objectContaining({ provider: "host-only-fixture", status: "unavailable" }), - ); - expect(snapshot.entries).toContainEqual( - expect.objectContaining({ provider: "opencode", status: "unavailable" }), - ); - const hostProbeCwds = (await readFileFromHost(root, "host-provider-probed.txt")) - ?.trim() - .split("\n"); - expect(hostProbeCwds).not.toContain(repo); - } - - const workspaceId = workspaceIds[0]!; - const cwd = publicCwds.get(workspaceId)!; - const workspaceGit = publicClient.bindWorkspaceGit({ workspaceId, cwd }); - await expect(workspaceGit.getStatus()).resolves.toMatchObject({ - isGit: true, - isDirty: false, - }); - const agent = await publicClient.createAgent({ - provider: "docker-fixture", - model: "fixture-model-v1", - cwd, - workspaceId, - title: "Docker public fake provider", - }); - await publicClient.sendMessage(agent.id, "public docker edit"); - await expect(publicClient.waitForFinish(agent.id, 30_000)).resolves.toMatchObject({ - status: "idle", - }); - const edited = await publicClient.readFile( - cwd, - "stdio-agent-output.txt", - undefined, - workspaceId, - ); - expect(new TextDecoder().decode(edited.bytes)).toBe("public docker edit\n"); - const editedTrackedFile = await publicClient.readFile( - cwd, - "committed.txt", - undefined, - workspaceId, - ); - expect(new TextDecoder().decode(editedTrackedFile.bytes)).toBe("public docker edit\n"); - const dirtyDiff = await workspaceGit.getDiff({ mode: "uncommitted" }); - expect(dirtyDiff.files.map((file) => file.path)).toContain("committed.txt"); - const isolatedEnvironment = await publicClient.readFile( - cwd, - "stdio-agent-env.txt", - undefined, - workspaceId, - ); - expect(new TextDecoder().decode(isolatedEnvironment.bytes)).toBe("\n"); - } finally { - await publicClient.close().catch(() => undefined); - await daemon.close(); - await Promise.all(workspaceIds.map((workspaceId) => seedRuntime.destroy(workspaceId))); - } -}, 180_000); - -test("Docker probe pause survives restart and source invalidation rotates the provider snapshot", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-probe-lifecycle-"); - const root = fixture.root; - const repo = await createRepository(root); - await writeFile(path.join(repo, "provider-model.txt"), "fixture-model-v1\n"); - execFileSync("git", ["add", "provider-model.txt"], { cwd: repo }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "provider v1"], { - cwd: repo, - }); - const paseoHomeRoot = path.join(root, "daemon-home"); - const paseoHome = path.join(paseoHomeRoot, ".paseo"); - await mkdir(paseoHome, { recursive: true }); - const projectId = "docker-probe-lifecycle-project"; - const projectRegistry = new FileBackedProjectRegistry( - path.join(paseoHome, "projects", "projects.json"), - createTestLogger(), - ); - await projectRegistry.initialize(); - const createdAt = new Date().toISOString(); - await projectRegistry.upsert( - createPersistedProjectRecord({ - projectId, - rootPath: repo, - kind: "git", - displayName: projectId, - createdAt, - updatedAt: createdAt, - }), - ); - const daemonOptions = { - paseoHomeRoot, - cleanup: false, - mcpEnabled: false, - agentClients: {}, - workspaceRuntimes: { docker: dockerRegistration() }, - providerOverrides: { - "docker-fixture": { - extends: "acp" as const, - label: "Docker Fixture", - command: ["node", "./fixture-agent.mjs"], - params: { supportsMcpServers: false }, - enabled: true, - }, - }, - }; - let daemon = await createTestPaseoDaemon(daemonOptions); - let client = new DaemonClient({ - url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.3.0-beta.2", - reconnect: { enabled: false }, - }); - let workspaceId = ""; - - try { - await client.connect(); - const firstProbe = await client.ensureWorkspaceRuntimeProbe({ projectId, runtimeId: "docker" }); - expect(firstProbe).toMatchObject({ status: "ready", error: null }); - workspaceId = firstProbe.workspaceId!; - fixture.workspace(workspaceId); - const firstSnapshot = await client.getProvidersSnapshot({ cwd: "/workspace", workspaceId }); - expect(firstSnapshot.entries).toContainEqual( - expect.objectContaining({ - provider: "docker-fixture", - status: "ready", - models: [expect.objectContaining({ id: "fixture-model-v1" })], - }), - ); - - await client.close(); - await daemon.close(); - expect(dockerRunningContainerCount(workspaceId)).toBe(0); - expect(dockerResourceCount("ps", workspaceId)).toBe(1); - expect(dockerResourceCount("volume", workspaceId)).toBe(1); - - await writeFile(path.join(repo, "provider-model.txt"), "fixture-model-v2\n"); - execFileSync("git", ["add", "provider-model.txt"], { cwd: repo }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "provider v2"], { - cwd: repo, - }); - - daemon = await createTestPaseoDaemon(daemonOptions); - client = new DaemonClient({ - url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.3.0-beta.2", - reconnect: { enabled: false }, - }); - await client.connect(); - const recreatedProbe = await client.ensureWorkspaceRuntimeProbe({ - projectId, - runtimeId: "docker", - }); - expect(recreatedProbe).toEqual({ - requestId: recreatedProbe.requestId, - workspaceId, - status: "ready", - error: null, - }); - const rotatedSnapshot = await client.getProvidersSnapshot({ cwd: "/workspace", workspaceId }); - expect(rotatedSnapshot.entries).toContainEqual( - expect.objectContaining({ - provider: "docker-fixture", - status: "ready", - models: [expect.objectContaining({ id: "fixture-model-v2" })], - }), - ); - - await expect(client.removeProject(projectId)).resolves.toEqual({ removedWorkspaceIds: [] }); - expect(dockerResourceCount("ps", workspaceId)).toBe(0); - expect(dockerResourceCount("volume", workspaceId)).toBe(0); - } finally { - await client.close().catch(() => undefined); - await daemon.close().catch(() => undefined); - } -}, 180_000); - -test("persisted Git sources materialize default and pinned revisions through Docker", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-git-source-"); - const root = fixture.root; - const sourceRepository = path.join(root, "source-repository"); - const bareRepository = path.join(root, "source.git"); - const decoyDefault = path.join(root, "decoy-default"); - const decoyPinned = path.join(root, "decoy-pinned"); - await mkdir(path.join(sourceRepository, "packages", "app"), { recursive: true }); - execFileSync("git", ["init", "-b", "main"], { cwd: sourceRepository, stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { - cwd: sourceRepository, - }); - execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: sourceRepository }); - await copyFile(fixtureAgent, path.join(sourceRepository, "packages", "app", "fixture-agent.mjs")); - await chmod(path.join(sourceRepository, "packages", "app", "fixture-agent.mjs"), 0o755); - await writeFile(path.join(sourceRepository, "packages", "app", "source-state.txt"), "pinned\n"); - await writeFile( - path.join(sourceRepository, "packages", "app", "provider-model.txt"), - "fixture-model-pinned\n", - ); - await writeFile( - path.join(sourceRepository, "packages", "app", "paseo.json"), - JSON.stringify({ - worktree: { - setup: ["node -e \"process.stdout.write('docker-setup-cwd:'+process.cwd())\""], - }, - }), - ); - execFileSync("git", ["add", "."], { cwd: sourceRepository }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pinned state"], { - cwd: sourceRepository, - }); - const pinnedRevision = execFileSync("git", ["rev-parse", "HEAD"], { - cwd: sourceRepository, - encoding: "utf8", - }).trim(); - await writeFile(path.join(sourceRepository, "packages", "app", "source-state.txt"), "default\n"); - await writeFile( - path.join(sourceRepository, "packages", "app", "provider-model.txt"), - "fixture-model-default\n", - ); - execFileSync("git", ["add", "."], { cwd: sourceRepository }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "default state"], { - cwd: sourceRepository, - }); - execFileSync("git", ["clone", "--bare", sourceRepository, bareRepository], { stdio: "pipe" }); - await mkdir(path.join(decoyDefault, "packages", "app"), { recursive: true }); - await mkdir(path.join(decoyPinned, "packages", "app"), { recursive: true }); - await writeFile(path.join(decoyDefault, "packages", "app", "source-state.txt"), "decoy\n"); - await writeFile(path.join(decoyPinned, "packages", "app", "source-state.txt"), "decoy\n"); - - const paseoHomeRoot = path.join(root, "daemon-home"); - const paseoHome = path.join(paseoHomeRoot, ".paseo"); - await mkdir(paseoHome, { recursive: true }); - const projectRegistry = new FileBackedProjectRegistry( - path.join(paseoHome, "projects", "projects.json"), - createTestLogger(), - ); - await projectRegistry.initialize(); - const now = new Date().toISOString(); - const sourceUrl = "file:///git/source.git"; - for (const project of [ - { - projectId: "docker-git-default", - rootPath: decoyDefault, - source: { kind: "git" as const, url: sourceUrl, subdirectory: "packages/app" }, - }, - { - projectId: "docker-git-pinned", - rootPath: decoyPinned, - source: { - kind: "git" as const, - url: sourceUrl, - revision: pinnedRevision, - subdirectory: "packages/app", - }, - }, - ]) { - await projectRegistry.upsert( - createPersistedProjectRecord({ - ...project, - kind: "git", - displayName: project.projectId, - createdAt: now, - updatedAt: now, - }), - ); - } - - const daemon = await createTestPaseoDaemon({ - paseoHomeRoot, - cleanup: false, - mcpEnabled: false, - agentClients: {}, - providerOverrides: { - "docker-fixture": { - extends: "acp", - label: "Docker Fixture", - command: ["node", "./fixture-agent.mjs"], - params: { supportsMcpServers: false }, - enabled: true, - }, - }, - workspaceRuntimes: { - docker: dockerRegistration({ - bindMounts: [{ source: bareRepository, target: "/git/source.git", readOnly: true }], - }), - }, - }); - const client = new DaemonClient({ - url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.3.1", - reconnect: { enabled: false }, - }); - const projectIds = ["docker-git-default", "docker-git-pinned"] as const; - - try { - await client.connect(); - const publicMessages: Parameters[0]>[0][] = []; - const unsubscribeMessages = client.subscribeRawMessages((message) => - publicMessages.push(message), - ); - onTestFinished(unsubscribeMessages); - const defaultProbe = await client.ensureWorkspaceRuntimeProbe({ - projectId: projectIds[0], - runtimeId: "docker", - }); - expect(defaultProbe).toMatchObject({ status: "ready", error: null }); - fixture.workspace(defaultProbe.workspaceId!); - const workspaceRoot = "/workspace/packages/app"; - const probeSnapshot = await client.getProvidersSnapshot({ - workspaceId: defaultProbe.workspaceId!, - cwd: workspaceRoot, - }); - expect(probeSnapshot.entries).toContainEqual( - expect.objectContaining({ - provider: "docker-fixture", - status: "ready", - models: [expect.objectContaining({ id: "fixture-model-default" })], - }), - ); - - const defaultWorkspace = await client.createWorkspace({ - source: { kind: "directory", path: decoyDefault, projectId: projectIds[0] }, - runtimeId: "docker", - }); - if (!defaultWorkspace.workspace) { - throw new Error(defaultWorkspace.error ?? "Default Git-source workspace creation failed"); - } - fixture.workspace(defaultWorkspace.workspace.id); - await expect - .poll(() => client.fetchWorkspaceSetupStatus(defaultWorkspace.workspace!.id), { - timeout: 30_000, - }) - .toMatchObject({ snapshot: { status: "completed", error: null } }); - const setupProgress = publicMessages.filter( - (message) => - message.type === "workspace_setup_progress" && - message.payload.workspaceId === defaultWorkspace.workspace?.id, - ); - expect(setupProgress).not.toHaveLength(0); - for (const message of setupProgress) { - if (message.type !== "workspace_setup_progress") continue; - expect(message.payload.detail.worktreePath).toBe(workspaceRoot); - expect(message.payload.detail.worktreePath).not.toContain(decoyDefault); - for (const command of message.payload.detail.commands) { - expect(command.cwd).toBe(workspaceRoot); - expect(command.cwd).not.toContain(decoyDefault); - } - } - const defaultFile = await client.readFile( - workspaceRoot, - "source-state.txt", - undefined, - defaultWorkspace.workspace.id, - ); - expect(new TextDecoder().decode(defaultFile.bytes)).toBe("default\n"); - - const pinnedWorkspace = await client.createWorkspace({ - source: { kind: "directory", path: decoyPinned, projectId: projectIds[1] }, - runtimeId: "docker", - }); - if (!pinnedWorkspace.workspace) { - throw new Error(pinnedWorkspace.error ?? "Pinned Git-source workspace creation failed"); - } - fixture.workspace(pinnedWorkspace.workspace.id); - const pinnedFile = await client.readFile( - workspaceRoot, - "source-state.txt", - undefined, - pinnedWorkspace.workspace.id, - ); - expect(new TextDecoder().decode(pinnedFile.bytes)).toBe("pinned\n"); - - for (const projectId of projectIds) await client.removeProject(projectId); - for (const workspaceId of [ - defaultProbe.workspaceId, - defaultWorkspace.workspace.id, - pinnedWorkspace.workspace.id, - ]) { - expect(dockerResourceCount("ps", workspaceId!)).toBe(0); - expect(dockerResourceCount("volume", workspaceId!)).toBe(0); - } - } finally { - await client.close().catch(() => undefined); - await daemon.close().catch(() => undefined); - } -}, 180_000); - -test("Docker creation validates roots and rolls back when runtime selection cannot persist", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-rollback-"); - const root = fixture.root; - const repo = await createRepository(root); - const workspaceId = `docker-rollback-${Date.now()}`; - fixture.workspace(workspaceId); - fixture.workspace(`${workspaceId}-escape`); - const service = createWorkspaceRuntimeService({ - paseoHome: path.join(root, "paseo-home"), - resolveRuntimeId: async () => null, - persistRuntimeId: async () => { - throw new Error("persistence failed"); - }, - externalRuntimes: { - docker: dockerRegistration(), - }, - }); - await expect( - service.create({ - workspaceId: `${workspaceId}-escape`, - runtimeId: "docker", - project: { id: "docker-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing", relativeCwd: "../escape" }, - }), - ).rejects.toThrow("Workspace cwd escapes its root"); - expect(dockerResourceCount("ps", `${workspaceId}-escape`)).toBe(0); - expect(dockerResourceCount("volume", `${workspaceId}-escape`)).toBe(0); - - await expect( - service.create({ - workspaceId, - runtimeId: "docker", - project: { id: "docker-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }), - ).rejects.toThrow("persistence failed"); - expect(dockerResourceCount("ps", workspaceId)).toBe(0); - expect(dockerResourceCount("volume", workspaceId)).toBe(0); -}, 120_000); - -test("Docker rejects a committed symlink cwd that resolves outside the selected root", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-symlink-"); - const root = fixture.root; - const repo = await createRepository(root); - await mkdir(path.join(repo, "selected")); - await symlink("..", path.join(repo, "selected", "escape")); - execFileSync("git", ["add", "."], { cwd: repo }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "symlink fixture"], { - cwd: repo, - }); - const workspaceId = `docker-symlink-${Date.now()}`; - fixture.workspace(workspaceId); - const runtimeIds = new Map(); - const service = createWorkspaceRuntimeService({ - paseoHome: path.join(root, "paseo-home"), - resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, - persistRuntimeId: async (id, runtimeId) => { - runtimeIds.set(id, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (id) => { - runtimeIds.delete(id); - }, - externalRuntimes: { - docker: dockerRegistration(), - }, - }); - - try { - await service.create({ - workspaceId, - runtimeId: "docker", - project: { id: "symlink-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing", relativeCwd: "selected" }, - }); - const escaped = await service.run({ - workspaceId, - cwd: "escape", - argv: ["/bin/sh", "-c", "touch escaped-marker"], - env: {}, - purpose: { kind: "workspace-script", script: "realpath-confinement" }, - }); - escaped.stdin.end(); - const escapedError = collect(escaped.stderr); - await expect(escaped.exited).rejects.toThrow("Workspace cwd escapes its root"); - expect(await escapedError).toContain("Workspace cwd escapes its root"); - const verify = await service.run({ - workspaceId, - argv: ["/bin/sh", "-c", "test ! -e ../escaped-marker"], - env: {}, - purpose: { kind: "workspace-script", script: "realpath-confinement-proof" }, - }); - verify.stdin.end(); - await expect(verify.exited).resolves.toEqual({ code: 0, signal: null }); - } finally { - await service.destroy(workspaceId).catch(() => undefined); - } -}, 120_000); - -test("the Docker runtime never adopts or destroys an unowned deterministic resource", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-ownership-"); - const root = fixture.root; - const workspaceId = `docker-ownership-${Date.now()}`; - const volume = dockerResourceName(workspaceId); - fixture.workspace(workspaceId); - execFileSync("docker", ["volume", "create", volume]); - const runtimeIds = new Map([[workspaceId, "docker"]]); - const service = createWorkspaceRuntimeService({ - paseoHome: path.join(root, "paseo-home"), - resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, - persistRuntimeId: async (id, runtimeId) => { - runtimeIds.set(id, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (id) => { - runtimeIds.delete(id); - }, - externalRuntimes: { - docker: dockerRegistration(), - }, - }); - - try { - await expect( - service.create({ - workspaceId, - runtimeId: "docker", - project: { id: "ownership-project", source: { kind: "host-directory", path: root } }, - placement: { kind: "existing" }, - }), - ).rejects.toThrow(`Docker volume ownership mismatch: ${volume}`); - await expect(service.destroy(workspaceId)).rejects.toThrow( - `Docker volume ownership mismatch: ${volume}`, - ); - expect( - execFileSync("docker", ["volume", "inspect", "--format", "{{.Name}}", volume], { - encoding: "utf8", - }).trim(), - ).toBe(volume); - } finally { - execFileSync("docker", ["volume", "rm", volume]); - } -}, 30_000); - -test("a Docker runtime owner cannot inspect, adopt, pause, resume, or destroy another owner's workspace", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-cross-owner-"); - const repo = await createRepository(fixture.root); - const workspaceId = `docker-cross-owner-${Date.now()}`; - const resourceName = dockerResourceName(workspaceId); - fixture.workspace(workspaceId); - const owner = createDockerService(path.join(fixture.root, "owner-home")); - const foreign = createDockerService(path.join(fixture.root, "foreign-home")); - - await owner.create({ - workspaceId, - runtimeId: "docker", - project: { id: "cross-owner-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }); - - await expect(foreign.inspect(workspaceId)).rejects.toThrow( - `Docker container runtime owner mismatch: ${resourceName}`, - ); - await expect( - foreign.create({ - workspaceId, - runtimeId: "docker", - project: { id: "cross-owner-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }), - ).rejects.toThrow(`Docker container runtime owner mismatch: ${resourceName}`); - await expect(foreign.pause(workspaceId)).rejects.toThrow( - `Docker container runtime owner mismatch: ${resourceName}`, - ); - expect(dockerContainerRunning(resourceName)).toBe(true); - - await owner.pause(workspaceId); - await expect(foreign.resume(workspaceId)).rejects.toThrow( - `Docker container runtime owner mismatch: ${resourceName}`, - ); - expect(dockerContainerRunning(resourceName)).toBe(false); - await expect(foreign.destroy(workspaceId)).rejects.toThrow( - `Docker container runtime owner mismatch: ${resourceName}`, - ); - expect(dockerResourceExists("container", resourceName)).toBe(true); - expect(dockerResourceExists("volume", resourceName)).toBe(true); -}, 120_000); - -test("Docker lifecycle rejects split, unlabeled, and incomplete deterministic resources without mutation", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-mismatched-owner-"); - const paseoHome = path.join(fixture.root, "owner-home"); - const owner = dockerRuntimeOwner(paseoHome); - const foreignOwner = dockerRuntimeOwner(path.join(fixture.root, "foreign-home")); - const service = createDockerService(paseoHome); - - const ownContainerWorkspaceId = `docker-own-container-${Date.now()}`; - const ownContainerName = dockerResourceName(ownContainerWorkspaceId); - fixture.workspace(ownContainerWorkspaceId); - createDockerVolume(ownContainerName, ownContainerWorkspaceId, foreignOwner); - createDockerContainer(ownContainerName, ownContainerWorkspaceId, owner); - await expect(service.inspect(ownContainerWorkspaceId)).rejects.toThrow( - `Docker volume runtime owner mismatch: ${ownContainerName}`, - ); - await expect(service.destroy(ownContainerWorkspaceId)).rejects.toThrow( - `Docker volume runtime owner mismatch: ${ownContainerName}`, - ); - expect(dockerResourceExists("container", ownContainerName)).toBe(true); - expect(dockerResourceExists("volume", ownContainerName)).toBe(true); - - const ownVolumeWorkspaceId = `docker-own-volume-${Date.now()}`; - const ownVolumeName = dockerResourceName(ownVolumeWorkspaceId); - fixture.workspace(ownVolumeWorkspaceId); - createDockerVolume(ownVolumeName, ownVolumeWorkspaceId, owner); - createDockerContainer(ownVolumeName, ownVolumeWorkspaceId, foreignOwner); - await expect(service.inspect(ownVolumeWorkspaceId)).rejects.toThrow( - `Docker container runtime owner mismatch: ${ownVolumeName}`, - ); - await expect(service.destroy(ownVolumeWorkspaceId)).rejects.toThrow( - `Docker container runtime owner mismatch: ${ownVolumeName}`, - ); - expect(dockerResourceExists("container", ownVolumeName)).toBe(true); - expect(dockerResourceExists("volume", ownVolumeName)).toBe(true); - - const unlabeledWorkspaceId = `docker-unlabeled-volume-${Date.now()}`; - const unlabeledName = dockerResourceName(unlabeledWorkspaceId); - fixture.workspace(unlabeledWorkspaceId); - execFileSync("docker", ["volume", "create", unlabeledName], { stdio: "pipe" }); - createDockerContainer(unlabeledName, unlabeledWorkspaceId, owner); - await expect(service.inspect(unlabeledWorkspaceId)).rejects.toThrow( - `Docker volume ownership mismatch: ${unlabeledName}`, - ); - expect(dockerResourceExists("container", unlabeledName)).toBe(true); - expect(dockerResourceExists("volume", unlabeledName)).toBe(true); - - const unlabeledContainerWorkspaceId = `docker-unlabeled-container-${Date.now()}`; - const unlabeledContainerName = dockerResourceName(unlabeledContainerWorkspaceId); - fixture.workspace(unlabeledContainerWorkspaceId); - createDockerVolume(unlabeledContainerName, unlabeledContainerWorkspaceId, owner); - execFileSync( - "docker", - [ - "create", - "--name", - unlabeledContainerName, - "-v", - `${unlabeledContainerName}:/workspace`, - image, - "/bin/sh", - "-c", - "exec tail -f /dev/null", - ], - { stdio: "pipe" }, - ); - await expect(service.inspect(unlabeledContainerWorkspaceId)).rejects.toThrow( - `Docker container ownership mismatch: ${unlabeledContainerName}`, - ); - expect(dockerResourceExists("container", unlabeledContainerName)).toBe(true); - expect(dockerResourceExists("volume", unlabeledContainerName)).toBe(true); - - const volumeOnlyWorkspaceId = `docker-volume-only-${Date.now()}`; - const volumeOnlyName = dockerResourceName(volumeOnlyWorkspaceId); - fixture.workspace(volumeOnlyWorkspaceId); - createDockerVolume(volumeOnlyName, volumeOnlyWorkspaceId, owner); - await expect(service.inspect(volumeOnlyWorkspaceId)).rejects.toThrow( - `Docker workspace resources are incomplete: ${volumeOnlyWorkspaceId}`, - ); - await expect(service.destroy(volumeOnlyWorkspaceId)).rejects.toThrow( - `Docker workspace resources are incomplete: ${volumeOnlyWorkspaceId}`, - ); - await expect( - service.create({ - workspaceId: volumeOnlyWorkspaceId, - runtimeId: "docker", - project: { - id: "incomplete-project", - source: { kind: "host-directory", path: fixture.root }, - }, - placement: { kind: "existing" }, - }), - ).rejects.toThrow(`Docker workspace resources are incomplete: ${volumeOnlyWorkspaceId}`); - expect(dockerResourceExists("volume", volumeOnlyName)).toBe(true); - - const containerOnlyWorkspaceId = `docker-container-only-${Date.now()}`; - const containerOnlyName = dockerResourceName(containerOnlyWorkspaceId); - fixture.workspace(containerOnlyWorkspaceId); - createDockerContainer(containerOnlyName, containerOnlyWorkspaceId, owner, false); - await expect(service.inspect(containerOnlyWorkspaceId)).rejects.toThrow( - `Docker workspace resources are incomplete: ${containerOnlyWorkspaceId}`, - ); - await expect(service.destroy(containerOnlyWorkspaceId)).rejects.toThrow( - `Docker workspace resources are incomplete: ${containerOnlyWorkspaceId}`, - ); - expect(dockerResourceExists("container", containerOnlyName)).toBe(true); -}, 60_000); - -test("Docker creation revalidates a concurrently created foreign volume before materialization", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-volume-race-"); - const repo = await createRepository(fixture.root); - const workspaceId = `docker-volume-race-${Date.now()}`; - const resourceName = dockerResourceName(workspaceId); - fixture.workspace(workspaceId); - const paseoHome = path.join(fixture.root, "owner-home"); - const foreignOwner = dockerRuntimeOwner(path.join(fixture.root, "foreign-home")); - const attemptedMountPath = path.join(fixture.root, "foreign-volume-mounted"); - const wrapperDirectory = await createDockerRaceWrapper({ - root: fixture.root, - workspaceId, - resourceName, - foreignOwner, - attemptedMountPath, - race: "volume", - }); - const service = createCommandDockerService({ paseoHome, wrapperDirectory }); - - await expect( - service.create({ - workspaceId, - runtimeId: "docker-race", - project: { id: "volume-race-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }), - ).rejects.toThrow(`Docker volume runtime owner mismatch: ${resourceName}`); - - expect(inspectDockerResourceLabels("volume", resourceName)).toEqual({ - runtime: "workspace", - workspaceId, - runtimeOwner: foreignOwner, - }); - expect(readDockerVolumeFile(resourceName, "sentinel.txt")).toBe("foreign-volume-sentinel\n"); - expect(existsSync(attemptedMountPath)).toBe(false); - expect(dockerResourceExists("container", resourceName)).toBe(false); -}, 60_000); - -test("Docker creation revalidates a concurrently created foreign container before start", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-container-race-"); - const repo = await createRepository(fixture.root); - const workspaceId = `docker-container-race-${Date.now()}`; - const resourceName = dockerResourceName(workspaceId); - fixture.workspace(workspaceId); - const paseoHome = path.join(fixture.root, "owner-home"); - const foreignOwner = dockerRuntimeOwner(path.join(fixture.root, "foreign-home")); - const attemptedMutationPath = path.join(fixture.root, "foreign-container-started"); - const wrapperDirectory = await createDockerRaceWrapper({ - root: fixture.root, - workspaceId, - resourceName, - foreignOwner, - attemptedMutationPath, - race: "container", - }); - const service = createCommandDockerService({ paseoHome, wrapperDirectory }); - - await expect( - service.create({ - workspaceId, - runtimeId: "docker-race", - project: { id: "container-race-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }), - ).rejects.toThrow(`Docker container runtime owner mismatch: ${resourceName}`); - - expect(inspectDockerResourceLabels("container", resourceName)).toEqual({ - runtime: "workspace", - workspaceId, - runtimeOwner: foreignOwner, - }); - expect(dockerContainerRunning(resourceName)).toBe(false); - expect(existsSync(attemptedMutationPath)).toBe(false); - expect(dockerResourceExists("volume", resourceName)).toBe(true); -}, 60_000); - -test("trusted Docker configuration mounts one runtime credential file read-only", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-bind-"); - const repo = await createRepository(fixture.root); - const mountedFile = path.join(fixture.root, "credential.txt"); - await writeFile(mountedFile, "runtime-credential\n", { mode: 0o600 }); - const runtimeIds = new Map(); - const service = createWorkspaceRuntimeService({ - paseoHome: path.join(fixture.root, "paseo-home"), - resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, - persistRuntimeId: async (workspaceId, runtimeId) => { - runtimeIds.set(workspaceId, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (workspaceId) => { - runtimeIds.delete(workspaceId); - }, - externalRuntimes: { - docker: dockerRegistration({ - bindMounts: [ - { - source: mountedFile, - target: "/opt/paseo-workspace-runtime/credential.txt", - readOnly: true, - }, - ], - }), - }, - }); - const workspaceId = `docker-bind-${Date.now()}`; - fixture.workspace(workspaceId); - - try { - await service.create({ - workspaceId, - runtimeId: "docker", - project: { id: "docker-bind-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }); - const read = await service.run({ - workspaceId, - argv: ["/bin/cat", "/opt/paseo-workspace-runtime/credential.txt"], - env: {}, - purpose: { kind: "provider-probe", provider: "fixture" }, - }); - read.stdin.end(); - await expect(collect(read.stdout)).resolves.toBe("runtime-credential\n"); - await expect(read.exited).resolves.toEqual({ code: 0, signal: null }); - - const codexVersion = await service.run({ - workspaceId, - argv: ["codex", "--version"], - env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, - purpose: { kind: "provider-probe", provider: "codex" }, - }); - codexVersion.stdin.end(); - await expect(collect(codexVersion.stdout)).resolves.toContain("codex-cli 0.144.0"); - await expect(codexVersion.exited).resolves.toEqual({ code: 0, signal: null }); - - const write = await service.run({ - workspaceId, - argv: ["/bin/sh", "-c", "printf changed > /opt/paseo-workspace-runtime/credential.txt"], - env: {}, - purpose: { kind: "provider-probe", provider: "fixture" }, - }); - write.stdin.end(); - await expect(write.exited).resolves.toMatchObject({ code: expect.any(Number) }); - expect((await write.exited).code).not.toBe(0); - await expect(readFile(mountedFile, "utf8")).resolves.toBe("runtime-credential\n"); - } finally { - await service.destroy(workspaceId).catch(() => undefined); - } -}, 30_000); - -test("Docker reconciliation removes only orphaned resources with deterministic names and ownership labels", async () => { - const fixture = await dockerTestFixture("paseo-runtime-docker-reconcile-"); - const root = fixture.root; - const orphanWorkspaceId = `docker-orphan-${Date.now()}`; - const foreignWorkspaceId = `${orphanWorkspaceId}-foreign`; - const otherDaemonWorkspaceId = `${orphanWorkspaceId}-other-daemon`; - const orphanVolume = dockerResourceName(orphanWorkspaceId); - const foreignVolume = `${dockerResourceName(foreignWorkspaceId)}-foreign`; - const otherDaemonVolume = dockerResourceName(otherDaemonWorkspaceId); - const paseoHome = path.join(root, "paseo-home"); - const owner = createHash("sha256").update(path.resolve(paseoHome)).digest("hex"); - const otherOwner = createHash("sha256") - .update(`${path.resolve(paseoHome)}-other`) - .digest("hex"); - fixture.volume(orphanVolume); - fixture.volume(foreignVolume); - fixture.volume(otherDaemonVolume); - execFileSync("docker", [ - "volume", - "create", - "--label", - "sh.paseo.runtime=workspace", - "--label", - `sh.paseo.workspace-id=${orphanWorkspaceId}`, - "--label", - `sh.paseo.runtime-owner=${owner}`, - orphanVolume, - ]); - execFileSync("docker", [ - "volume", - "create", - "--label", - "sh.paseo.runtime=workspace", - "--label", - `sh.paseo.workspace-id=${foreignWorkspaceId}`, - foreignVolume, - ]); - execFileSync("docker", [ - "volume", - "create", - "--label", - "sh.paseo.runtime=workspace", - "--label", - `sh.paseo.workspace-id=${otherDaemonWorkspaceId}`, - "--label", - `sh.paseo.runtime-owner=${otherOwner}`, - otherDaemonVolume, - ]); - const service = createWorkspaceRuntimeService({ - paseoHome, - resolveRuntimeId: async () => null, - persistRuntimeId: async () => undefined, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async () => {}, - listRuntimeRecords: async () => [], - externalRuntimes: { - docker: dockerRegistration(), - }, - }); - - try { - await service.reconcile(); - expect(dockerResourceCount("volume", orphanWorkspaceId)).toBe(0); - expect( - execFileSync("docker", ["volume", "inspect", "--format", "{{.Name}}", foreignVolume], { - encoding: "utf8", - }).trim(), - ).toBe(foreignVolume); - expect( - execFileSync("docker", ["volume", "inspect", "--format", "{{.Name}}", otherDaemonVolume], { - encoding: "utf8", - }).trim(), - ).toBe(otherDaemonVolume); - } finally { - execFileSync("docker", ["volume", "rm", "-f", orphanVolume, foreignVolume, otherDaemonVolume], { - stdio: "ignore", - }); - } -}, 30_000); - -async function dockerTestFixture(prefix: string): Promise<{ - root: string; - workspace(workspaceId: string): void; - volume(name: string): void; -}> { - let root: string | null = null; - const containers = new Set(); - const volumes = new Set(); - const workspaceIds = new Set(); - onTestFinished(async () => { - await Promise.all( - [...containers].map((name) => - run("docker", ["rm", "-f", name], { timeout: 10_000 }).catch(() => undefined), - ), - ); - await Promise.all( - [...volumes].map((name) => - run("docker", ["volume", "rm", "-f", name], { timeout: 10_000 }).catch(() => undefined), - ), - ); - const leakedContainers = [...containers].filter((name) => - dockerResourceExists("container", name), - ); - const leakedVolumes = [...volumes].filter((name) => dockerResourceExists("volume", name)); - const runningProcesses = dockerTestProcessPids(workspaceIds); - if (runningProcesses.length > 0) { - await run("kill", ["-KILL", ...runningProcesses.map(String)], { timeout: 5_000 }).catch( - () => undefined, - ); - } - const leakedProcesses = dockerTestProcessPids(workspaceIds); - if (root) await rm(root, { recursive: true, force: true }); - if (leakedContainers.length || leakedVolumes.length || leakedProcesses.length) { - throw new Error( - `Docker test leaked containers=${leakedContainers.join(",")} volumes=${leakedVolumes.join(",")} processes=${leakedProcesses.join(",")}`, - ); - } - }, 30_000); - root = await mkdtemp(path.join(tmpdir(), prefix)); - return { - root, - workspace(workspaceId) { - workspaceIds.add(workspaceId); - const name = dockerResourceName(workspaceId); - containers.add(name); - volumes.add(name); - }, - volume(name) { - volumes.add(name); - }, - }; -} - -function dockerResourceExists(kind: "container" | "volume", name: string): boolean { - try { - execFileSync("docker", [kind, "inspect", name], { stdio: "ignore", timeout: 5_000 }); - return true; - } catch { - return false; - } -} - -function dockerRuntimeOwner(paseoHome: string): string { - return createHash("sha256").update(path.resolve(paseoHome)).digest("hex"); -} - -function createDockerService(paseoHome: string) { - const runtimeIds = new Map(); - return createWorkspaceRuntimeService({ - paseoHome, - resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? "docker", - persistRuntimeId: async (workspaceId, runtimeId) => { - runtimeIds.set(workspaceId, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (workspaceId) => { - runtimeIds.delete(workspaceId); - }, - externalRuntimes: { docker: dockerRegistration() }, - }); -} - -function createCommandDockerService(input: { paseoHome: string; wrapperDirectory: string }) { - const runtimeIds = new Map(); - return createWorkspaceRuntimeService({ - paseoHome: input.paseoHome, - resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? "docker-race", - persistRuntimeId: async (workspaceId, runtimeId) => { - runtimeIds.set(workspaceId, runtimeId); - }, - beginWorkspaceDeletion: async () => {}, - removeWorkspaceRecord: async (workspaceId) => { - runtimeIds.delete(workspaceId); - }, - externalRuntimes: { - "docker-race": { - type: "command", - command: [ - "/usr/bin/env", - `PATH=${input.wrapperDirectory}:${process.env.PATH ?? ""}`, - process.execPath, - runtimeExecutable, - ], - options: { image }, - }, - }, - }); -} - -async function createDockerRaceWrapper(input: { - root: string; - workspaceId: string; - resourceName: string; - foreignOwner: string; - attemptedMountPath?: string; - attemptedMutationPath?: string; - race: "container" | "volume"; -}): Promise { - const directory = path.join(input.root, "docker-race-bin"); - await mkdir(directory); - const executable = path.join(directory, "docker"); - const realDocker = execFileSync("which", ["docker"], { encoding: "utf8" }).trim(); - await writeFile( - executable, - `#!/usr/bin/env node -import { spawnSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; -const args = process.argv.slice(2); -const realDocker = ${JSON.stringify(realDocker)}; -const resourceName = ${JSON.stringify(input.resourceName)}; -const race = ${JSON.stringify(input.race)}; -if (race === "volume" && args[0] === "volume" && args[1] === "create" && args.at(-1) === resourceName) { - const created = spawnSync(realDocker, ["volume", "create", "--label", "sh.paseo.runtime=workspace", "--label", ${JSON.stringify(`sh.paseo.workspace-id=${input.workspaceId}`)}, "--label", ${JSON.stringify(`sh.paseo.runtime-owner=${input.foreignOwner}`)}, resourceName], { stdio: "inherit" }); - if (created.status !== 0) process.exit(created.status ?? 1); - const seeded = spawnSync(realDocker, ["run", "--rm", "-v", resourceName + ":/foreign", ${JSON.stringify(image)}, "/bin/sh", "-c", "printf 'foreign-volume-sentinel\\n' > /foreign/sentinel.txt"], { stdio: "inherit" }); - process.exit(seeded.status ?? 1); -} -if (race === "container" && args[0] === "create" && args.includes(resourceName)) { - const created = spawnSync(realDocker, ["create", "--name", resourceName, "--label", "sh.paseo.runtime=workspace", "--label", ${JSON.stringify(`sh.paseo.workspace-id=${input.workspaceId}`)}, "--label", ${JSON.stringify(`sh.paseo.runtime-owner=${input.foreignOwner}`)}, "--label", "sh.paseo.workspace-root=/workspace", "--label", "sh.paseo.workspace-revision=foreign-revision", "-v", resourceName + ":/workspace", ${JSON.stringify(image)}, "/bin/sh", "-c", "exec tail -f /dev/null"], { stdio: "inherit" }); - process.exit(created.status ?? 1); -} -if (race === "volume" && args[0] === "run" && args.some((arg) => arg.startsWith(resourceName + ":"))) { - writeFileSync(${JSON.stringify(input.attemptedMountPath ?? "")}, "mounted\\n"); -} -if (race === "container" && args[0] === "start" && args.includes(resourceName)) { - writeFileSync(${JSON.stringify(input.attemptedMutationPath ?? "")}, "started\\n"); -} -const delegated = spawnSync(realDocker, args, { stdio: "inherit" }); -process.exit(delegated.status ?? 1); -`, - { mode: 0o755 }, - ); - return directory; -} - -function inspectDockerResourceLabels( - kind: "container" | "volume", - name: string, -): { runtime: string | null; workspaceId: string | null; runtimeOwner: string | null } { - const labels = JSON.parse( - execFileSync( - "docker", - [ - kind, - "inspect", - "--format", - kind === "container" ? "{{json .Config.Labels}}" : "{{json .Labels}}", - name, - ], - { encoding: "utf8" }, - ), - ) as Record | null; - return { - runtime: labels?.["sh.paseo.runtime"] ?? null, - workspaceId: labels?.["sh.paseo.workspace-id"] ?? null, - runtimeOwner: labels?.["sh.paseo.runtime-owner"] ?? null, - }; -} - -function readDockerVolumeFile(name: string, relativePath: string): string { - return execFileSync( - "docker", - ["run", "--rm", "-v", `${name}:/volume:ro`, image, "/bin/cat", `/volume/${relativePath}`], - { encoding: "utf8" }, - ); -} - -function createDockerVolume(name: string, workspaceId: string, owner: string): void { - execFileSync( - "docker", - [ - "volume", - "create", - "--label", - "sh.paseo.runtime=workspace", - "--label", - `sh.paseo.workspace-id=${workspaceId}`, - "--label", - `sh.paseo.runtime-owner=${owner}`, - name, - ], - { stdio: "pipe" }, - ); -} - -function createDockerContainer( - name: string, - workspaceId: string, - owner: string, - mountVolume = true, -): void { - execFileSync( - "docker", - [ - "create", - "--name", - name, - "--label", - "sh.paseo.runtime=workspace", - "--label", - `sh.paseo.workspace-id=${workspaceId}`, - "--label", - `sh.paseo.runtime-owner=${owner}`, - "--label", - "sh.paseo.workspace-root=/workspace", - "--label", - "sh.paseo.workspace-revision=test-revision", - ...(mountVolume ? ["-v", `${name}:/workspace`] : []), - image, - "/bin/sh", - "-c", - "exec tail -f /dev/null", - ], - { stdio: "pipe" }, - ); -} - -function dockerContainerRunning(name: string): boolean { - return ( - execFileSync("docker", ["inspect", "--format", "{{.State.Running}}", name], { - encoding: "utf8", - }).trim() === "true" - ); -} - -function dockerTestProcessPids(workspaceIds: ReadonlySet): number[] { - if (workspaceIds.size === 0) return []; - const output = execFileSync("ps", ["-axo", "pid=,command="], { - encoding: "utf8", - timeout: 5_000, - }); - const needles = [...workspaceIds].flatMap((workspaceId) => [ - dockerResourceName(workspaceId), - `--workspace-id ${workspaceId}`, - ]); - return output - .split("\n") - .map((line) => line.match(/^\s*(\d+)\s+(.*)$/)) - .filter( - (match): match is RegExpMatchArray => - !!match && needles.some((needle) => match[2]!.includes(needle)), - ) - .map((match) => Number(match[1])); -} - -async function createRepository(root: string): Promise { - const repo = path.join(root, "repo"); - await mkdir(repo); - execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { cwd: repo }); - execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repo }); - await writeFile(path.join(repo, "committed.txt"), "committed\n"); - await writeFile(path.join(repo, "large.bin"), Buffer.alloc(700_000, 0xa5)); - await copyFile(fixtureAgent, path.join(repo, "fixture-agent.mjs")); - await chmod(path.join(repo, "fixture-agent.mjs"), 0o755); - await symlink("/etc/passwd", path.join(repo, "outside-link")); - execFileSync("git", ["add", "."], { cwd: repo }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], { - cwd: repo, - }); - return repo; -} - -async function collectBytes(chunks: AsyncIterable): Promise { - const buffers: Buffer[] = []; - for await (const chunk of chunks) buffers.push(Buffer.from(chunk)); - return Buffer.concat(buffers); -} - -async function waitForOutput(stream: NodeJS.ReadableStream, marker: string): Promise { - return new Promise((resolve, reject) => { - let output = ""; - const onData = (chunk: Buffer | string) => { - output += chunk.toString(); - if (!output.includes(marker)) return; - stream.off("data", onData); - stream.off("error", onError); - stream.off("end", onEnd); - resolve(output); - }; - const onError = (error: Error) => { - stream.off("data", onData); - stream.off("end", onEnd); - reject(error); - }; - const onEnd = () => { - stream.off("data", onData); - stream.off("error", onError); - reject(new Error(`Stream ended before output marker: ${marker}`)); - }; - stream.on("data", onData); - stream.once("error", onError); - stream.once("end", onEnd); - }); -} - -function dockerProcesses(workspaceId: string): string { - const container = dockerResourceName(workspaceId); - return execFileSync("docker", ["top", container, "-eo", "pid,args"], { encoding: "utf8" }); -} - -function dockerResourceName(workspaceId: string): string { - return `paseo-ws-${createHash("sha256").update(workspaceId).digest("hex").slice(0, 20)}`; -} - -function dockerResourceCount(kind: "ps" | "volume", workspaceId: string): number { - const args = - kind === "ps" - ? ["ps", "-aq", "--filter", `label=sh.paseo.workspace-id=${workspaceId}`] - : ["volume", "ls", "-q", "--filter", `label=sh.paseo.workspace-id=${workspaceId}`]; - return execFileSync("docker", args, { encoding: "utf8" }).trim() ? 1 : 0; -} - -function dockerRunningContainerCount(workspaceId: string): number { - return execFileSync( - "docker", - ["ps", "-q", "--filter", `label=sh.paseo.workspace-id=${workspaceId}`], - { encoding: "utf8" }, - ).trim() - ? 1 - : 0; -} - -function nextFile(directory: string, expectedName: string): Promise { - return new Promise((resolve, reject) => { - const expected = path.join(directory, expectedName); - const watcher = watch(directory, (_event, filename) => { - if (filename?.toString() !== expectedName) return; - finish(); - }); - watcher.once("error", reject); - if (existsSync(expected)) finish(); - - function finish(): void { - watcher.close(); - resolve(); - } - }); -} - -function dockerBarrierShim(realDocker: string): string { - return `#!/usr/bin/env node -import { spawn } from "node:child_process"; -import { existsSync, watch } from "node:fs"; -import { writeFile } from "node:fs/promises"; -import path from "node:path"; - -const args = process.argv.slice(2); -const barrier = process.env.PASEO_DOCKER_TEST_BARRIER; -const readiness = barrier && args.includes("paseo-ready"); -const signal = barrier && args.includes("paseo-signal"); -if (readiness) await writeFile(path.join(barrier, "readiness-waiting"), "waiting"); -if (barrier && args[0] === "exec" && args.includes("/opt/paseo-workspace-runtime/workload.mjs")) { - await writeFile(path.join(barrier, "entered"), "entered"); - await nextFile(barrier, "release"); - await nextOneOf(barrier, ["readiness-waiting", "signal-finished"]); -} -const child = spawn(${JSON.stringify(realDocker)}, args, { stdio: "inherit" }); -child.once("error", error => { process.stderr.write(String(error)); process.exitCode = 127; }); -child.once("exit", async (code, exitSignal) => { - if (signal) await writeFile(path.join(barrier, "signal-finished"), "finished"); - if (exitSignal) { process.removeAllListeners(exitSignal); process.kill(process.pid, exitSignal); } - else process.exitCode = code ?? 1; -}); - -function nextFile(directory, name) { - const expected = path.join(directory, name); - if (existsSync(expected)) return Promise.resolve(); - return new Promise((resolve, reject) => { - const watcher = watch(directory, (_event, filename) => { - if (filename?.toString() !== name) return; - watcher.close(); - resolve(); - }); - watcher.once("error", reject); - if (existsSync(expected)) { watcher.close(); resolve(); } - }); -} - -function nextOneOf(directory, names) { - if (names.some(name => existsSync(path.join(directory, name)))) return Promise.resolve(); - return new Promise((resolve, reject) => { - const watcher = watch(directory, (_event, filename) => { - if (!filename || !names.includes(filename.toString())) return; - watcher.close(); - resolve(); - }); - watcher.once("error", reject); - if (names.some(name => existsSync(path.join(directory, name)))) { watcher.close(); resolve(); } - }); -} -`; -} - -async function readFileFromHost(root: string, name: string): Promise { - try { - return await (await import("node:fs/promises")).readFile(path.join(root, name), "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; - throw error; - } -} - -function createLogger() { - const logger = { - child: () => logger, - debug: vi.fn(), - info: vi.fn(), - trace: vi.fn(), - warn: vi.fn(), - }; - return logger; -} diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts index 64df571d87..765250db8a 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.posix.test.ts @@ -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, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index a5de5baedb..263cd0bf64 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -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"], }); }; } diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index b30f3c7090..5643e30bb0 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -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; + bindWorkspaceRuntime?: (workspaceId: string) => Promise; } 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" }, + }); + }, + }; +} diff --git a/packages/server/src/utils/string-command-shell.ts b/packages/server/src/utils/string-command-shell.ts index 6d4fd80af6..e6b0b88167 100644 --- a/packages/server/src/utils/string-command-shell.ts +++ b/packages/server/src/utils/string-command-shell.ts @@ -9,8 +9,8 @@ export interface StringCommandShellInvocation { args: string[]; } -export function createStringCommandShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const sanitized = { ...env }; +export function createStringCommandShellEnv(env: T): T { + const sanitized = { ...env } as T; delete sanitized.BASH_ENV; return sanitized; } diff --git a/packages/server/src/utils/worktree-shell-selection.test.ts b/packages/server/src/utils/worktree-shell-selection.test.ts index 6c703e7188..ddb6717e96 100644 --- a/packages/server/src/utils/worktree-shell-selection.test.ts +++ b/packages/server/src/utils/worktree-shell-selection.test.ts @@ -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, +): Promise { + 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"]); + }); }); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 983823adc2..8b7d1eb24f 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -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; + resolveCommand(command: string): Promise; + run(input: { + argv: readonly [string, ...string[]]; + cwd: string; + env: Readonly>; + }): 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; +} + 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 { @@ -452,13 +490,15 @@ async function execSetupCommandStreamed(options: { total: number; signal?: AbortSignal; onEvent?: (event: WorktreeSetupCommandProgressEvent) => void; + launch(): Promise; }): Promise { return new Promise((resolvePromise) => { const startedAt = Date.now(); const stdoutChunks: string[] = []; const stderrChunks: string[] = []; let settled = false; - let termination: Promise | null = null; + let termination: Promise | 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 { + await child.signal("SIGTERM"); + const exited = await Promise.race([ + child.exited.then(() => true), + new Promise((resolvePromise) => { + const timer = setTimeout(() => resolvePromise(false), 1_000); + timer.unref(); + }), + ]); + if (!exited) await child.signal("SIGKILL"); + await child.exited; +} + async function getAvailablePort(): Promise { 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 { - // 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 { + 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 { + 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 { + 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 { 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, }); diff --git a/packages/workspace-helper/README.md b/packages/workspace-helper/README.md new file mode 100644 index 0000000000..48e9b33d57 --- /dev/null +++ b/packages/workspace-helper/README.md @@ -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. diff --git a/packages/workspace-helper/package.json b/packages/workspace-helper/package.json new file mode 100644 index 0000000000..cd511e940c --- /dev/null +++ b/packages/workspace-helper/package.json @@ -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" + } +} diff --git a/packages/server/src/server/workspace-helper/internal/integration/index.ts b/packages/workspace-helper/src/binding.ts similarity index 77% rename from packages/server/src/server/workspace-helper/internal/integration/index.ts rename to packages/workspace-helper/src/binding.ts index 355354e586..864ed57ead 100644 --- a/packages/server/src/server/workspace-helper/internal/integration/index.ts +++ b/packages/workspace-helper/src/binding.ts @@ -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; } -/** @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; diff --git a/packages/server/src/server/workspace-helper/internal/client.ts b/packages/workspace-helper/src/client.ts similarity index 99% rename from packages/server/src/server/workspace-helper/internal/client.ts rename to packages/workspace-helper/src/client.ts index 8a9fac9bcb..8efffe050b 100644 --- a/packages/server/src/server/workspace-helper/internal/client.ts +++ b/packages/workspace-helper/src/client.ts @@ -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, diff --git a/packages/server/src/server/workspace-helper/executable.mjs b/packages/workspace-helper/src/executable.mjs old mode 100644 new mode 100755 similarity index 99% rename from packages/server/src/server/workspace-helper/executable.mjs rename to packages/workspace-helper/src/executable.mjs index d16b5407e8..0bcf91fc74 --- a/packages/server/src/server/workspace-helper/executable.mjs +++ b/packages/workspace-helper/src/executable.mjs @@ -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"; diff --git a/packages/server/src/server/workspace-helper/index.ts b/packages/workspace-helper/src/files.ts similarity index 100% rename from packages/server/src/server/workspace-helper/index.ts rename to packages/workspace-helper/src/files.ts diff --git a/packages/workspace-helper/src/index.ts b/packages/workspace-helper/src/index.ts new file mode 100644 index 0000000000..62459283c9 --- /dev/null +++ b/packages/workspace-helper/src/index.ts @@ -0,0 +1,3 @@ +export * from "./binding.js"; +export * from "./files.js"; +export { WORKSPACE_HELPER_PROTOCOL_VERSION, workspaceHelperExecutable } from "./location.js"; diff --git a/packages/workspace-helper/src/location.ts b/packages/workspace-helper/src/location.ts new file mode 100644 index 0000000000..2c03e3a813 --- /dev/null +++ b/packages/workspace-helper/src/location.ts @@ -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), +); diff --git a/packages/server/src/server/workspace-helper/internal/protocol.ts b/packages/workspace-helper/src/protocol.ts similarity index 100% rename from packages/server/src/server/workspace-helper/internal/protocol.ts rename to packages/workspace-helper/src/protocol.ts diff --git a/packages/server/src/server/workspace-helper/fixtures/adversarial-helper.mjs b/packages/workspace-helper/test/fixtures/adversarial-helper.mjs similarity index 95% rename from packages/server/src/server/workspace-helper/fixtures/adversarial-helper.mjs rename to packages/workspace-helper/test/fixtures/adversarial-helper.mjs index 7ff6898642..c4303bded3 100644 --- a/packages/server/src/server/workspace-helper/fixtures/adversarial-helper.mjs +++ b/packages/workspace-helper/test/fixtures/adversarial-helper.mjs @@ -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") { diff --git a/packages/workspace-helper/test/standalone.test.mjs b/packages/workspace-helper/test/standalone.test.mjs new file mode 100644 index 0000000000..9499eb37ab --- /dev/null +++ b/packages/workspace-helper/test/standalone.test.mjs @@ -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)); + } +} diff --git a/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts b/packages/workspace-helper/test/workspace-helper.posix.test.ts similarity index 88% rename from packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts rename to packages/workspace-helper/test/workspace-helper.posix.test.ts index 8e3270b5c5..3513b412eb 100644 --- a/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts +++ b/packages/workspace-helper/test/workspace-helper.posix.test.ts @@ -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): Promise { } 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((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( diff --git a/packages/docker-workspace-runtime/tsconfig.json b/packages/workspace-helper/tsconfig.json similarity index 77% rename from packages/docker-workspace-runtime/tsconfig.json rename to packages/workspace-helper/tsconfig.json index e6da0cc9d7..8984b07200 100644 --- a/packages/docker-workspace-runtime/tsconfig.json +++ b/packages/workspace-helper/tsconfig.json @@ -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"] }, diff --git a/packages/workspace-runtime-contract/README.md b/packages/workspace-runtime-contract/README.md index 07bbea4efa..1a8d335395 100644 --- a/packages/workspace-runtime-contract/README.md +++ b/packages/workspace-runtime-contract/README.md @@ -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. diff --git a/packages/workspace-runtime-contract/examples/v1.json b/packages/workspace-runtime-contract/examples/v1.json index c8e724cf83..7d42acd3c9 100644 --- a/packages/workspace-runtime-contract/examples/v1.json +++ b/packages/workspace-runtime-contract/examples/v1.json @@ -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", diff --git a/packages/workspace-runtime-contract/src/index.ts b/packages/workspace-runtime-contract/src/index.ts index 8d011bc60d..41d0bb25e2 100644 --- a/packages/workspace-runtime-contract/src/index.ts +++ b/packages/workspace-runtime-contract/src/index.ts @@ -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(), ]); diff --git a/packages/workspace-runtime-contract/test/contract.test.mjs b/packages/workspace-runtime-contract/test/contract.test.mjs index 39c0fc1d23..bd1a198a32 100644 --- a/packages/workspace-runtime-contract/test/contract.test.mjs +++ b/packages/workspace-runtime-contract/test/contract.test.mjs @@ -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" }, []], diff --git a/public-docs/configuration.md b/public-docs/configuration.md index cd2c8844b0..f385037cf7 100644 --- a/public-docs/configuration.md +++ b/public-docs/configuration.md @@ -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 diff --git a/runtimes/fixture/package.json b/runtimes/fixture/package.json new file mode 100644 index 0000000000..61143f2824 --- /dev/null +++ b/runtimes/fixture/package.json @@ -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" + } +} diff --git a/packages/fixture-workspace-runtime/src/index.mjs b/runtimes/fixture/src/index.mjs similarity index 96% rename from packages/fixture-workspace-runtime/src/index.mjs rename to runtimes/fixture/src/index.mjs index fde3fd128a..0a4c3e5574 100755 --- a/packages/fixture-workspace-runtime/src/index.mjs +++ b/runtimes/fixture/src/index.mjs @@ -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`); } diff --git a/packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs b/runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs similarity index 100% rename from packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs rename to runtimes/fixture/test/fixtures/workspace-runtime-acp-agent.mjs diff --git a/scripts/check-workspace-runtime-boundaries.mjs b/scripts/check-workspace-runtime-boundaries.mjs index b8252cf84e..63aaddb8b6 100644 --- a/scripts/check-workspace-runtime-boundaries.mjs +++ b/scripts/check-workspace-runtime-boundaries.mjs @@ -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, "", "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, "", "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 ""; +} + +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)); diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 72eb540639..9b36a3d934 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -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.*", ], diff --git a/scripts/server-clean-build.test.mjs b/scripts/server-clean-build.test.mjs new file mode 100644 index 0000000000..150e285d99 --- /dev/null +++ b/scripts/server-clean-build.test.mjs @@ -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", + ); + } +} diff --git a/scripts/workspace-runtime-boundaries.test.mjs b/scripts/workspace-runtime-boundaries.test.mjs index 35e611494d..8282d92db4 100644 --- a/scripts/workspace-runtime-boundaries.test.mjs +++ b/scripts/workspace-runtime-boundaries.test.mjs @@ -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", + "", + ]), + ); +}); + +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: "", + rule: "nonliteral-module-load", + message: + "packages/server/src/server/nonliteral-runtime-load.ts: nonliteral-module-load forbids ", + }, + ]); +}); + +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: "", + rule: "nonliteral-module-load", + message: + "packages/server/src/server/workspace-runtime/command/internal/command-runtime.ts: nonliteral-module-load forbids ", + }, + ]); +}); + +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", + "", ]), ); }); @@ -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"); +} diff --git a/scripts/workspace-runtime-standalone.test.mjs b/scripts/workspace-runtime-standalone.test.mjs new file mode 100644 index 0000000000..47fcb6d807 --- /dev/null +++ b/scripts/workspace-runtime-standalone.test.mjs @@ -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", +];