From 6ca27a9b1273b127d89057ebd98e56995c5a95ac Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 11 Aug 2026 22:14:47 +0200 Subject: [PATCH] feat(workspaces): publish external runtime contract Publish the rootless workspace runtime protocol, enforce module and host-execution boundaries, and prove an independently authored command runtime across lifecycle, PTY, files, Git, providers, and CI. --- .github/ci-paths.yml | 2 + .github/workflows/ci.yml | 8 + .github/workflows/docker.yml | 2 + .oxlintrc.json | 108 +++- package-lock.json | 55 +- package.json | 17 +- .../docker-workspace-runtime/package.json | 3 + .../docker-workspace-runtime/src/index.ts | 363 ++++++++--- .../docker-workspace-runtime/src/workload.mjs | 7 +- .../fixture-workspace-runtime/package.json | 18 + .../fixture-workspace-runtime/src/index.mjs | 524 ++++++++++++++++ packages/server/package.json | 1 + .../acp-workspace-runtime.posix.test.ts | 24 +- .../server/agent/providers/workspace/index.ts | 86 ++- .../workspace/workspace.posix.test.ts | 17 +- packages/server/src/server/config.test.ts | 9 +- ...workspace-helper-shared-rebind-fixture.mjs | 54 -- .../workspace-runtime-command-fixture.mjs | 323 ---------- ...ce-git-service.runtime.integration.test.ts | 159 ++--- .../server/workspace-helper/executable.mjs | 53 +- .../workspace-helper/internal/client.ts | 47 +- .../{ => internal}/integration/index.ts | 6 +- .../workspace-helper.posix.test.ts | 41 +- .../command/internal/command-runtime.ts | 331 +++++++--- .../command/internal/protocol.ts | 74 --- .../server/workspace-runtime/drivers/index.ts | 7 +- .../git-observation/integration.ts | 445 -------------- .../git-observation/internal/integration.ts | 111 ++++ .../src/server/workspace-runtime/index.ts | 6 +- .../host-git-observation.posix.test.ts | 135 ++++ .../internal/host-git-observation.ts | 110 ++++ .../internal/local-runtime.ts | 73 ++- .../internal/runtime-state.ts | 29 +- .../workspace-runtime/internal/service.ts | 102 +-- .../internal/worktree-runtime.ts | 98 +-- .../workspace-runtime.command.test.ts | 271 +++++++- .../workspace-runtime.docker.e2e.test.ts | 329 ++++++++-- .../workspace-runtime.posix.test.ts | 68 +- packages/workspace-runtime-contract/README.md | 45 ++ .../examples/v1.json | 90 +++ .../workspace-runtime-contract/package.json | 33 + .../workspace-runtime-contract/src/index.ts | 348 +++++++++++ .../test/contract.test.mjs | 581 ++++++++++++++++++ .../test/pipe-fixture.mjs | 56 ++ .../test/pty-fixture.mjs | 42 ++ .../test/public-types.ts | 26 + .../workspace-runtime-contract/tsconfig.json | 12 + .../tsconfig.test.json | 8 + .../check-workspace-runtime-boundaries.mjs | 228 +++++++ scripts/ci-workflow.test.mjs | 8 +- scripts/workspace-runtime-boundaries.test.mjs | 214 +++++++ 51 files changed, 4253 insertions(+), 1554 deletions(-) create mode 100644 packages/fixture-workspace-runtime/package.json create mode 100755 packages/fixture-workspace-runtime/src/index.mjs delete mode 100644 packages/server/src/server/test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs delete mode 100644 packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs rename packages/server/src/server/workspace-helper/{ => internal}/integration/index.ts (83%) delete mode 100644 packages/server/src/server/workspace-runtime/command/internal/protocol.ts delete mode 100644 packages/server/src/server/workspace-runtime/git-observation/integration.ts create mode 100644 packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts create mode 100644 packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts create mode 100644 packages/server/src/server/workspace-runtime/internal/host-git-observation.ts create mode 100644 packages/workspace-runtime-contract/README.md create mode 100644 packages/workspace-runtime-contract/examples/v1.json create mode 100644 packages/workspace-runtime-contract/package.json create mode 100644 packages/workspace-runtime-contract/src/index.ts create mode 100644 packages/workspace-runtime-contract/test/contract.test.mjs create mode 100644 packages/workspace-runtime-contract/test/pipe-fixture.mjs create mode 100644 packages/workspace-runtime-contract/test/pty-fixture.mjs create mode 100644 packages/workspace-runtime-contract/test/public-types.ts create mode 100644 packages/workspace-runtime-contract/tsconfig.json create mode 100644 packages/workspace-runtime-contract/tsconfig.test.json create mode 100644 scripts/check-workspace-runtime-boundaries.mjs create mode 100644 scripts/workspace-runtime-boundaries.test.mjs diff --git a/.github/ci-paths.yml b/.github/ci-paths.yml index c156ec0a16..d53ecfc778 100644 --- a/.github/ci-paths.yml +++ b/.github/ci-paths.yml @@ -35,6 +35,8 @@ hub: server: - "packages/server/**" + - "packages/fixture-workspace-runtime/**" + - "packages/workspace-runtime-contract/**" - "packages/app/e2e/support/fixtures/recording.*" desktop: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7fa5c814c..5f7eacd42f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,9 @@ jobs: - name: Lint run: npm run lint + - name: Enforce workspace runtime ownership boundaries + run: npm run check:workspace-runtime-boundaries && node --test scripts/workspace-runtime-boundaries.test.mjs + typecheck: name: typecheck needs: changes @@ -129,6 +132,7 @@ jobs: - name: Verify public package contents 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/client npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server @@ -163,6 +167,10 @@ jobs: - name: Build server dependencies run: npm run build:server-deps + - 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 + - name: Run server tests if: ${{ needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' }} run: npm run test --workspace=@getpaseo/server diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index efddeba1e6..7b4df067f2 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -17,6 +17,8 @@ on: - "packages/cli/**" - "packages/client/**" - "packages/docker-workspace-runtime/**" + - "packages/fixture-workspace-runtime/**" + - "packages/workspace-runtime-contract/**" - "packages/expo-two-way-audio/**" - "packages/highlight/**" - "packages/protocol/**" diff --git a/.oxlintrc.json b/.oxlintrc.json index e7e9951d3d..ab2232edb7 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -92,13 +92,19 @@ { "patterns": [ { - "group": ["**/workspace-helper/integration/**", "**/workspace-helper/internal/**"], + "group": [ + "**/workspace-runtime/internal/**", + "**/workspace-runtime/command/internal/**" + ], + "message": "Workspace runtime internals are owned by their public entry points." + }, + { + "group": ["**/workspace-helper/internal/**"], "message": "Normal callers consume WorkspaceFiles from workspace-helper/index. Only the workspace-runtime parent integration and workspace-helper tests may assemble helper clients." }, { "group": [ "**/workspace-runtime/integration/**", - "**/workspace-runtime/git-observation/integration*", "**/workspace-runtime/git-observation/internal/**" ], "message": "Git callers consume workspace-runtime/git-observation/index. Only runtime internals may assemble shared observation identity, placement, helpers, and lifecycle." @@ -110,6 +116,10 @@ }, { "files": [ + "packages/server/src/server/workspace-runtime/index.ts", + "packages/server/src/server/workspace-runtime/command/index.ts", + "packages/server/src/server/workspace-runtime/command/internal/**/*.{ts,tsx}", + "packages/server/src/server/workspace-runtime/internal/**/*.{ts,tsx}", "packages/server/src/server/workspace-helper/**/*.{ts,tsx}", "packages/server/src/server/workspace-runtime/git-observation/**/*.{ts,tsx}", "packages/server/src/server/workspace-runtime/internal/service.ts" @@ -118,6 +128,100 @@ "no-restricted-imports": "off" } }, + { + "files": [ + "packages/server/src/server/session/files/**/*.{ts,tsx}", + "packages/server/src/server/session/git-mutation/**/*.{ts,tsx}", + "packages/server/src/server/session/provider/**/*.{ts,tsx}", + "packages/server/src/server/session/workspace-git-observer/**/*.{ts,tsx}", + "packages/server/src/server/session/workspace-provisioning/**/*.{ts,tsx}", + "packages/server/src/server/session/workspace-scripts/**/*.{ts,tsx}" + ], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { "name": "child_process", "message": "Use the bound workspace runtime." }, + { "name": "node:child_process", "message": "Use the bound workspace runtime." }, + { "name": "fs", "message": "Use the bound workspace runtime." }, + { "name": "node:fs", "message": "Use the bound workspace runtime." }, + { "name": "fs/promises", "message": "Use the bound workspace runtime." }, + { "name": "node:fs/promises", "message": "Use the bound workspace runtime." }, + { "name": "module", "message": "Dynamic loaders bypass workspace ownership." }, + { + "name": "node:module", + "message": "Dynamic loaders bypass workspace ownership." + }, + { "name": "which", "message": "Use the bound workspace runtime." } + ], + "patterns": [ + { + "group": ["**/utils/run-git-command.{js,ts}"], + "message": "Use the bound workspace runtime." + }, + { + "group": [ + "**/workspace-runtime/internal/**", + "**/workspace-runtime/command/internal/**", + "**/workspace-runtime/git-observation/internal/**", + "**/workspace-helper/internal/**" + ], + "message": "Use the owning public entry point." + } + ] + } + ] + } + }, + { + "files": [ + "packages/server/src/server/session/files/**/*.test.{ts,tsx}", + "packages/server/src/server/session/git-mutation/**/*.test.{ts,tsx}", + "packages/server/src/server/session/provider/**/*.test.{ts,tsx}", + "packages/server/src/server/session/workspace-git-observer/**/*.test.{ts,tsx}", + "packages/server/src/server/session/workspace-provisioning/**/*.test.{ts,tsx}", + "packages/server/src/server/session/workspace-scripts/**/*.test.{ts,tsx}" + ], + "rules": { + "no-restricted-imports": "off" + } + }, + { + "files": ["packages/fixture-workspace-runtime/**/*.{js,mjs,ts,tsx}"], + "rules": { + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "module", + "message": "The external runtime may not construct dynamic module loaders." + }, + { + "name": "node:module", + "message": "The external runtime may not construct dynamic module loaders." + }, + { + "name": "@getpaseo/server", + "message": "The external runtime may consume only the published command contract." + } + ], + "patterns": [ + { + "group": [ + "@getpaseo/server/**", + "**/packages/server/**", + "**/workspace-runtime/**/internal/**", + "**/workspace-helper/**/internal/**" + ], + "message": "The external runtime may consume only the published command contract." + } + ] + } + ] + } + }, { "files": ["packages/app/src/**/*.{ts,tsx}"], "rules": { diff --git a/package-lock.json b/package-lock.json index 787019f1fe..a927a5bd61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,8 @@ "packages/website", "packages/desktop", "packages/cli", + "packages/workspace-runtime-contract", + "packages/fixture-workspace-runtime", "packages/docker-workspace-runtime" ], "devDependencies": { @@ -6243,6 +6245,10 @@ "resolved": "packages/expo-two-way-audio", "link": true }, + "node_modules/@getpaseo/fixture-workspace-runtime": { + "resolved": "packages/fixture-workspace-runtime", + "link": true + }, "node_modules/@getpaseo/highlight": { "resolved": "packages/highlight", "link": true @@ -6263,6 +6269,10 @@ "resolved": "packages/website", "link": true }, + "node_modules/@getpaseo/workspace-runtime-contract": { + "resolved": "packages/workspace-runtime-contract", + "link": true + }, "node_modules/@gorhom/bottom-sheet": { "version": "5.2.14", "resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.14.tgz", @@ -27494,6 +27504,16 @@ "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", "license": "MIT" }, + "node_modules/node-pty": { + "version": "1.2.0-beta.15", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz", + "integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -36652,6 +36672,9 @@ "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" }, @@ -37556,6 +37579,17 @@ "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", @@ -38030,6 +38064,7 @@ "@getpaseo/highlight": "0.3.1", "@getpaseo/protocol": "0.3.1", "@getpaseo/relay": "0.3.1", + "@getpaseo/workspace-runtime-contract": "0.3.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", @@ -38373,16 +38408,6 @@ "node": "20 || >=22" } }, - "packages/server/node_modules/node-pty": { - "version": "1.2.0-beta.15", - "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.15.tgz", - "integrity": "sha512-vORSzHXi4Ofl7HemVWpuudLqCPdaQb4LfpRCUpE5HPxhp4JYscl8zZwxh11p26v2wvW24WMwnMfLjhRLixrfxA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^7.1.0" - } - }, "packages/server/node_modules/openai": { "version": "6.44.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.44.0.tgz", @@ -40288,6 +40313,16 @@ "optional": true } } + }, + "packages/workspace-runtime-contract": { + "name": "@getpaseo/workspace-runtime-contract", + "version": "0.3.1", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^5.9.3" + } } } } diff --git a/package.json b/package.json index 0378efe487..48f90c17d0 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "packages/website", "packages/desktop", "packages/cli", + "packages/workspace-runtime-contract", + "packages/fixture-workspace-runtime", "packages/docker-workspace-runtime" ], "scripts": { @@ -53,7 +55,9 @@ "build:protocol:clean": "npm run build:clean --workspace=@getpaseo/protocol", "build:client": "npm run build --workspace=@getpaseo/client", "build:client:clean": "npm run build:protocol:clean && npm run build:clean --workspace=@getpaseo/client", - "build:server-deps": "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: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:server-deps": "npm run build:workspace-runtime-contract && concurrently --kill-others-on-fail --names highlight,relay,client --prefix-colors yellow,blue,cyan \"npm run build:highlight\" \"npm run build:relay\" \"npm run build:client\"", "build:server-deps:clean": "npm run build:highlight:clean && npm run build:relay:clean && npm run build:client:clean", "build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli", "build:server:clean": "npm run build:server-deps:clean && npm run build:clean --workspace=@getpaseo/server && npm run build:clean --workspace=@getpaseo/cli", @@ -70,6 +74,7 @@ "format:check": "oxfmt --check .", "format:check:files": "oxfmt --check", "lint": "oxlint", + "check:workspace-runtime-boundaries": "node scripts/check-workspace-runtime-boundaries.mjs", "lint:fix": "oxlint --fix", "knip": "knip", "acp:version-drift": "node scripts/check-acp-catalog-version-drift.mjs", @@ -100,11 +105,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 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/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/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/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/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/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 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: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/docker-workspace-runtime/package.json b/packages/docker-workspace-runtime/package.json index d14dbeba3d..79e1774b0a 100644 --- a/packages/docker-workspace-runtime/package.json +++ b/packages/docker-workspace-runtime/package.json @@ -10,6 +10,9 @@ "build": "tsc -p tsconfig.json", "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 index c8b04afe94..56cd882d71 100644 --- a/packages/docker-workspace-runtime/src/index.ts +++ b/packages/docker-workspace-runtime/src/index.ts @@ -8,69 +8,52 @@ 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"; -interface RuntimeState { - workspaceId: string; +type RuntimeState = CommandRuntimeState; +type PrivateRuntimeState = RuntimeState & { root: string; revision: string; - executionDomainId: string; - lifecycle: "ready" | "paused"; + container: string; lifecycleEnvironment: Readonly>; -} - -interface LifecycleRequest { - protocolVersion: 1; - input?: { - workspaceId: string; - project: { - source: - | { kind: "host-directory"; path: string } - | { kind: "git"; url: string; revision: string; subdirectory?: string }; - }; - placement: { relativeCwd?: string }; - }; - options: { image?: unknown }; - workspaceIds?: string[]; -} - -interface ExecRequest { - type: "spawn"; - protocolVersion: 1; - argv: [string, ...string[]]; - cwd?: string; - env: Record; - purpose: - | { kind: "agent"; agentId: string; provider: string } - | { kind: "terminal"; terminalId: string } - | { kind: "git" } - | { kind: "provider-probe"; provider: string } - | { kind: "workspace-helper" } - | { kind: "workspace-script"; script: string } - | { kind: "setup" } - | { kind: "archive" }; - options: { image?: unknown }; - execId: string; - stdio: { kind: "pipes" } | { kind: "pty"; rows: number; cols: number; term?: string }; -} - -type PtyControl = - | ExecRequest - | { type: "resize"; protocolVersion: 1; id: number; rows: number; cols: number } - | { type: "signal"; protocolVersion: 1; signal: NodeJS.Signals }; +}; +type LifecycleRequest = CommandRuntimeLifecycleRequest; +type ExecRequest = CommandRuntimeSpawnEnvelope; +type PtyControl = CommandRuntimeControl; 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({ protocolVersion: 1, modes: ["pipes", "pty"], reconcile: true }); + writeJson(CommandRuntimeDescribeResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + modes: ["pipes", "pty"], + reconcile: true, + }); } else if (operation === "reconcile") { - const request = JSON.parse(await readStdin()) as LifecycleRequest; - assertProtocol(request.protocolVersion); + const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin())); await reconcile(request.workspaceIds ?? []); - writeJson({ protocolVersion: 1, type: "ok" }); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "ok", + }); } else if (!requestedWorkspaceId) { throw new Error("--workspace-id is required"); } else if (operation === "exec") { @@ -83,45 +66,50 @@ try { requireSignal(requireArgument("--signal")), ); } else { - const request = JSON.parse(await readStdin()) as LifecycleRequest; - assertProtocol(request.protocolVersion); + const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin())); switch (operation) { case "create": { const state = await create(requestedWorkspaceId, request); - writeJson({ - protocolVersion: 1, + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "state", - state, + state: publicState(state), placement: runtimePlacement(state), }); } break; case "inspect": - writeJson({ - protocolVersion: 1, + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "inspection", inspection: await inspect(requestedWorkspaceId), }); break; case "pause": await pause(requestedWorkspaceId); - writeJson({ protocolVersion: 1, type: "ok" }); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "ok", + }); break; case "resume": { const state = await resume(requestedWorkspaceId); - writeJson({ - protocolVersion: 1, + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "state", - state, + state: publicState(state), placement: runtimePlacement(state), }); } break; case "destroy": await destroy(requestedWorkspaceId); - writeJson({ protocolVersion: 1, type: "ok" }); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "ok", + }); break; default: throw new Error(`Unknown operation: ${operation}`); @@ -132,8 +120,11 @@ try { process.exitCode = 1; } -async function create(workspaceId: string, request: LifecycleRequest): Promise { - const existing = await inspect(workspaceId); +async function create( + workspaceId: string, + request: LifecycleRequest, +): Promise { + const existing = await inspectPrivate(workspaceId); if (existing.status === "ready" || existing.status === "paused") return existing.state; const input = request.input; if (!input) throw new Error("create input is required"); @@ -240,11 +231,11 @@ async function create(workspaceId: string, request: LifecycleRequest): Promise { @@ -277,25 +268,33 @@ async function inspect(workspaceId: string): Promise< }; } +async function inspect(workspaceId: string) { + const inspection = await inspectPrivate(workspaceId); + if (inspection.status === "ready" || inspection.status === "paused") { + return { ...inspection, state: publicState(inspection.state) }; + } + return inspection; +} + async function pause(workspaceId: string): Promise { - const current = await inspect(workspaceId); + const current = await inspectPrivate(workspaceId); if (current.status === "missing" || current.status === "paused") return; await docker(["stop", "--time", "5", resourceNames(workspaceId).container]); } -async function resume(workspaceId: string): Promise { - const current = await inspect(workspaceId); +async function resume(workspaceId: string): Promise { + const current = await inspectPrivate(workspaceId); if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`); if (current.status === "paused") await docker(["start", resourceNames(workspaceId).container]); await waitUntilReady(resourceNames(workspaceId).container); - const ready = await inspect(workspaceId); + const ready = await inspectPrivate(workspaceId); if (ready.status !== "ready") throw new Error(`Docker workspace did not resume: ${workspaceId}`); return ready.state; } async function destroy(workspaceId: string): Promise { const names = resourceNames(workspaceId); - const current = await inspect(workspaceId); + const current = await inspectPrivate(workspaceId); const volume = await inspectVolume(names.volume); if (volume && (volume.runtime !== "workspace" || volume.owner !== workspaceId)) { throw new Error(`Docker volume ownership mismatch: ${names.volume}`); @@ -375,10 +374,10 @@ async function execute(workspaceId: string): Promise { const iterator = lines[Symbol.asyncIterator](); const first = await iterator.next(); if (first.done) throw new Error("Runtime exec spawn control is required"); - const request = JSON.parse(first.value) as ExecRequest; - assertProtocol(request.protocolVersion); + 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 current = await inspect(workspaceId); + const current = await inspectPrivate(workspaceId); if (current.status !== "ready") throw new Error(`Docker workspace is ${current.status}`); const requestedCwd = resolveWorkspaceCwd(current.state.root, request.cwd); const cwd = ( @@ -396,10 +395,23 @@ async function execute(workspaceId: string): Promise { 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") { - await executeDockerPty({ workspaceId, request, controls: iterator, encoded, container }); + 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", [ @@ -425,18 +437,76 @@ async function execute(workspaceId: string): Promise { }, ); let forwardedSignal: NodeJS.Signals | null = null; + let forwardedSignalCompletion: Promise | null = null; + let forwardedSignalFailure: unknown; const forwardSignal = async (signal: NodeJS.Signals): Promise => { forwardedSignal = signal; - await signalExec(workspaceId, execId, signal); + try { + await withTimeout( + signalExec(workspaceId, execId, signal), + PTY_CLEANUP_TIMEOUT_MS, + "Docker signal helper timed out", + ); + } catch (error) { + if (signal === "SIGKILL") throw error; + try { + await withTimeout( + signalExec(workspaceId, execId, "SIGKILL"), + 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 = () => void forwardSignal(signal).catch(() => child.kill(signal)); + const handler = () => { + forwardedSignalCompletion = forwardSignal(signal).catch((error) => { + forwardedSignalFailure = error; + }); + }; hostSignalHandlers.set(signal, handler); process.on(signal, handler); } - const exit = await exitPromise; + 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); @@ -455,6 +525,7 @@ async function executeDockerPty(input: { 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 }); @@ -472,6 +543,15 @@ async function executeDockerPty(input: { ); 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(); @@ -490,7 +570,11 @@ async function executeDockerPty(input: { requestedSignal = signal; }, (id) => { - writePtyEvent(events, { type: "resized", protocolVersion: 1, id }); + writePtyEvent(events, { + type: "resized", + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + id, + }); }, ).then( () => new Promise(() => {}), @@ -525,14 +609,14 @@ async function executeDockerPty(input: { firstOutcome === "control" ? await controlCleanup : { failure: null, cleanupError: null }; executionFinished = true; process.stdin.unpipe(stream); - writePtyEvent(events, { type: "eof", protocolVersion: 1 }); + 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: 1, + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, message: `${outcome.failure.message}${cleanupDetail}`, }); events.end(); @@ -544,13 +628,89 @@ async function executeDockerPty(input: { ); writePtyEvent(events, { type: "exit", - protocolVersion: 1, + 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 { @@ -576,8 +736,7 @@ async function handlePtyControls( while (true) { const next = await controls.next(); if (next.done) return; - const control = JSON.parse(next.value) as PtyControl; - assertProtocol(control.protocolVersion); + const control: PtyControl = CommandRuntimeControlSchema.parse(JSON.parse(next.value)); if (control.type === "resize") { await resizeDockerExec(dockerExecId, control.cols, control.rows); onResize(control.id); @@ -599,7 +758,7 @@ async function resizeDockerExec(execId: string, cols: number, rows: number): Pro } function writePtyEvent(stream: Socket, event: unknown): void { - stream.write(`${JSON.stringify(event)}\n`); + stream.write(encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event)); } async function dockerApiJson>( @@ -666,16 +825,18 @@ async function signalExec( signal: NodeJS.Signals, ): Promise { if (!/^[a-f0-9]{32}$/.test(execId)) throw new Error("Invalid exec id"); - const current = await inspect(workspaceId); + const current = await inspectPrivate(workspaceId); 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", - 'test ! -f "$1" || { read -r supervisor workload < "$1"; signal=${2#SIG}; kill -"$signal" "-$workload" 2>/dev/null || kill -"$signal" "$workload" 2>/dev/null || true; kill -"$signal" "$supervisor" 2>/dev/null || true; }', + '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", - `/tmp/paseo-runtime-exec-${execId}.pid`, + files.pid, + files.done, signal, ]); } @@ -726,23 +887,31 @@ function stateFor( revision: string, container: string, lifecycle: "ready" | "paused" = "ready", -): RuntimeState { +): PrivateRuntimeState { return { workspaceId, root, revision, - executionDomainId: `docker:${container}`, + container, lifecycle, lifecycleEnvironment: { - PASEO_SOURCE_CHECKOUT_PATH: "/workspace", - PASEO_ROOT_PATH: "/workspace", - PASEO_WORKTREE_PATH: "/workspace", + PASEO_SOURCE_CHECKOUT_PATH: ".", + PASEO_ROOT_PATH: ".", + PASEO_WORKTREE_PATH: ".", PASEO_BRANCH_NAME: "", }, }; } -function runtimePlacement(state: RuntimeState): { cwd: string } { +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 }; } @@ -788,12 +957,8 @@ async function readStream(stream: NodeJS.ReadableStream): Promise { return Buffer.concat(chunks).toString("utf8"); } -function writeJson(value: unknown): void { - process.stdout.write(`${JSON.stringify(value)}\n`); -} - -function assertProtocol(version: unknown): asserts version is 1 { - if (version !== 1) throw new Error(`Unsupported protocol version: ${String(version)}`); +function writeJson(schema: CommandRuntimeMessageSchema, value: unknown): void { + process.stdout.write(encodeCommandRuntimeMessage(schema, value)); } function argument(flag: string): string | undefined { diff --git a/packages/docker-workspace-runtime/src/workload.mjs b/packages/docker-workspace-runtime/src/workload.mjs index 9201d3099d..0abacde771 100644 --- a/packages/docker-workspace-runtime/src/workload.mjs +++ b/packages/docker-workspace-runtime/src/workload.mjs @@ -9,6 +9,8 @@ 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( @@ -26,7 +28,6 @@ const child = spawn(request.argv[0], request.argv.slice(1), { stdio: ["inherit", "inherit", "inherit"], }); if (!child.pid) throw new Error("Runtime workload did not start"); -writeFileSync(pidFile, `${process.pid} ${child.pid}`); for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { process.on(signal, () => { @@ -38,14 +39,18 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { } }); } +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); diff --git a/packages/fixture-workspace-runtime/package.json b/packages/fixture-workspace-runtime/package.json new file mode 100644 index 0000000000..fc25e171b2 --- /dev/null +++ b/packages/fixture-workspace-runtime/package.json @@ -0,0 +1,18 @@ +{ + "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/fixture-workspace-runtime/src/index.mjs b/packages/fixture-workspace-runtime/src/index.mjs new file mode 100755 index 0000000000..5f8d808bb9 --- /dev/null +++ b/packages/fixture-workspace-runtime/src/index.mjs @@ -0,0 +1,524 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { watch } from "node:fs"; +import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { Socket } from "node:net"; +import { createInterface } from "node:readline"; +import * as pty from "node-pty"; +import { + COMMAND_RUNTIME_PROTOCOL_VERSION, + CommandRuntimeControlSchema, + CommandRuntimeDescribeResponseSchema, + CommandRuntimeLifecycleRequestSchema, + CommandRuntimeLifecycleResponseSchema, + CommandRuntimeProcessEventSchema, + encodeCommandRuntimeMessage, +} from "@getpaseo/workspace-runtime-contract"; + +const operation = process.argv + .slice(2) + .find((value) => + ["describe", "create", "inspect", "exec", "signal", "pause", "resume", "destroy"].includes( + value, + ), + ); +const workspaceId = argument("--workspace-id"); +let pipeChild = null; +let forwardedPipeSignal = null; +const pipeSignalHandlers = new Map(); +if (operation === "exec") { + for (const signalName of ["SIGINT", "SIGTERM", "SIGHUP"]) { + const handler = () => { + forwardedPipeSignal = signalName; + if (pipeChild) killGroup(pipeChild.pid, signalName); + }; + pipeSignalHandlers.set(signalName, handler); + process.on(signalName, handler); + } +} + +try { + if (operation === "describe") { + const protocolVersion = Number( + argument("--protocol-version") ?? COMMAND_RUNTIME_PROTOCOL_VERSION, + ); + const response = { + protocolVersion, + modes: argument("--modes") === "pipes" ? ["pipes"] : ["pipes", "pty"], + }; + if (protocolVersion === COMMAND_RUNTIME_PROTOCOL_VERSION) { + writeJson(CommandRuntimeDescribeResponseSchema, response); + } else { + process.stdout.write(`${JSON.stringify(response)}\n`); + } + } else if (!workspaceId) { + throw new Error("--workspace-id is required"); + } else if (operation === "exec") { + await execute(workspaceId); + } else if (operation === "signal") { + await signal(workspaceId, requireArgument("--exec-id"), requireArgument("--signal")); + } else { + const request = CommandRuntimeLifecycleRequestSchema.parse( + JSON.parse(await readStream(process.stdin)), + ); + switch (operation) { + case "create": + { + const state = await create(workspaceId, request); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "state", + state: publicState(state), + placement: publicPlacement(state), + }); + } + break; + case "inspect": + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "inspection", + inspection: await inspect(workspaceId, request.options), + }); + break; + case "pause": + await setLifecycle(workspaceId, request.options, "paused"); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "ok", + }); + break; + case "resume": + { + const state = await setLifecycle(workspaceId, request.options, "ready"); + writeJson(CommandRuntimeLifecycleResponseSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "state", + state: publicState(state), + placement: publicPlacement(state), + }); + } + break; + case "destroy": + await rm(stateFile(workspaceId, request.options), { force: true }); + 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(id, request) { + const existing = await readState(id, request.options); + if (existing) return existing; + const root = request.input.project.source.path; + const state = { + workspaceId: id, + root, + displayCwd: request.options.displayCwd ?? root, + hostVisiblePath: request.options.exposeHostVisiblePath === false ? undefined : root, + lifecycle: "ready", + }; + await mkdir(request.options.stateDirectory, { recursive: true }); + await writeFile(stateFile(id, request.options), JSON.stringify(state)); + return state; +} + +async function inspect(id, options) { + await applyInspectBarrier(options); + const state = await readState(id, options); + return state + ? { status: state.lifecycle, state: publicState(state), placement: publicPlacement(state) } + : { status: "missing" }; +} + +function publicState(state) { + return { workspaceId: state.workspaceId, lifecycle: state.lifecycle }; +} + +function publicPlacement(state) { + return { + cwd: state.displayCwd, + ...(state.hostVisiblePath ? { hostVisiblePath: state.hostVisiblePath } : {}), + }; +} + +async function setLifecycle(id, options, lifecycle) { + const state = await readState(id, options); + if (!state) throw new Error(`Fixture workspace is missing: ${id}`); + const updated = { ...state, lifecycle }; + await writeFile(stateFile(id, options), JSON.stringify(updated)); + return updated; +} + +async function execute(id) { + const 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("spawn control is required"); + const envelope = CommandRuntimeControlSchema.parse(JSON.parse(first.value)); + if (envelope.type !== "spawn") throw new Error("spawn control is required"); + const state = await readState(id, envelope.options); + if (!state) throw new Error(`Fixture workspace is missing: ${id}`); + if (envelope.options.recordLaunchInWorkspace !== false) { + await writeFile( + path.join(state.root, ".runtime-launch.json"), + JSON.stringify({ argv: process.argv, purpose: envelope.purpose }), + ); + } + if (envelope.stdio.kind === "pty") { + for (const [signalName, handler] of pipeSignalHandlers) process.off(signalName, handler); + await executePty(id, envelope, iterator, control); + control.destroy(); + return; + } + const events = new Socket({ fd: 4, readable: false, writable: true }); + pipeChild = spawn(envelope.argv[0], envelope.argv.slice(1), { + cwd: await resolveCwd(state.root, envelope.cwd), + env: envelope.env, + detached: true, + stdio: ["inherit", "inherit", "inherit"], + }); + const exitPromise = new Promise((resolve, reject) => { + pipeChild.once("error", reject); + pipeChild.once("exit", (code, signalName) => resolve({ code, signal: signalName })); + }); + await writeFile(execFile(id, envelope.execId, envelope.options), String(pipeChild.pid)); + if ( + envelope.options.recordWorkloadPidAt && + envelope.options.processEventPurposeKind === envelope.purpose.kind + ) { + await writeFile(envelope.options.recordWorkloadPidAt, String(pipeChild.pid)); + } + if (await emitConfiguredProcessEvents(events, envelope.options, envelope.purpose)) { + await exitPromise; + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + return; + } + events.write( + encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "started", + }), + ); + if (forwardedPipeSignal) killGroup(pipeChild.pid, forwardedPipeSignal); + if ( + envelope.options.crashPipeWrapper && + envelope.purpose.kind === "workspace-script" && + envelope.purpose.script === "wrapper-crash" + ) { + process.exit(47); + } + const exit = await exitPromise; + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + for (const [signalName, handler] of pipeSignalHandlers) process.off(signalName, handler); + const signalName = exit.signal ?? forwardedPipeSignal; + events.write( + encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "eof", + }), + ); + await new Promise((resolve, reject) => + events + .end( + encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "exit", + code: signalName ? null : exit.code, + signal: signalName, + }), + resolve, + ) + .once("error", reject), + ); + if (signalName) { + process.removeAllListeners(signalName); + process.kill(process.pid, signalName); + } else { + process.exitCode = exit.code ?? 1; + } +} + +async function executePty(id, envelope, controls, controlStream) { + const events = new Socket({ fd: 4, readable: false, writable: true }); + const child = pty.spawn(envelope.argv[0], envelope.argv.slice(1), { + cwd: await resolveCwd((await readState(id, envelope.options)).root, envelope.cwd), + env: envelope.env, + name: envelope.stdio.term ?? "xterm-256color", + cols: envelope.stdio.cols, + rows: envelope.stdio.rows, + }); + await writeFile(execFile(id, envelope.execId, envelope.options), String(child.pid)); + if ( + envelope.options.recordWorkloadPidAt && + envelope.options.processEventPurposeKind === envelope.purpose.kind + ) { + await writeFile(envelope.options.recordWorkloadPidAt, String(child.pid)); + } + if (await emitConfiguredProcessEvents(events, envelope.options, envelope.purpose)) { + await new Promise((resolve) => child.onExit(resolve)); + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + return; + } + events.write( + encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "started", + }), + ); + if (envelope.options.closePtyControl) setTimeout(() => controlStream.destroy(), 100); + process.stdin.on("data", (data) => child.write(data.toString())); + child.onData((data) => process.stdout.write(data)); + if (envelope.options.invalidPtyEvent) { + setTimeout( + () => + events.end( + `${JSON.stringify({ protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "invalid" })}\n`, + ), + 100, + ); + } + let requestedSignal = null; + void (async () => { + while (true) { + const next = await controls.next(); + if (next.done) return; + const control = CommandRuntimeControlSchema.parse(JSON.parse(next.value)); + if (control.type === "resize") { + child.resize(control.cols, control.rows); + events.write( + `${JSON.stringify({ protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "resized", id: control.id })}\n`, + ); + } else if (control.type === "signal") { + requestedSignal = control.signal; + child.kill(control.signal); + } else throw new Error(`Unexpected PTY control: ${control.type}`); + } + })().catch((error) => { + events.write( + `${JSON.stringify({ protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "error", message: error.message })}\n`, + ); + child.kill("SIGKILL"); + }); + const exit = await new Promise((resolve) => + child.onExit(({ exitCode, signal: signalNumber }) => + resolve({ exitCode, signal: signalNumber }), + ), + ); + await rm(execFile(id, envelope.execId, envelope.options), { force: true }); + process.stdin.destroy(); + if (envelope.options.invalidPtyEvent) return; + events.write( + `${JSON.stringify({ protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "eof" })}\n`, + ); + if (envelope.options.omitPtyExitEvent) { + events.end(); + return; + } + const exitEvent = `${JSON.stringify({ + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + type: "exit", + code: requestedSignal || exit.signal ? null : exit.exitCode, + signal: requestedSignal, + })}\n`; + if (envelope.options.delayedPtyExitEvent) { + const writer = spawn( + process.execPath, + [ + "-e", + `setTimeout(()=>{require('node:fs').writeSync(4,${JSON.stringify(exitEvent)});},1000)`, + ], + { detached: true, stdio: ["ignore", "ignore", "ignore", "ignore", events] }, + ); + writer.unref(); + events.destroy(); + return; + } + events.end(exitEvent); +} + +async function emitConfiguredProcessEvents(events, options, purpose) { + if (!Array.isArray(options.processEventSequence)) return false; + if (options.processEventPurposeKind !== purpose.kind) return false; + if (options.processEventBarrierPath) await waitForFile(options.processEventBarrierPath); + const values = options.processEventSequence.map((type) => { + if (type === "exit") { + return { type, protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, code: 0, signal: null }; + } + if (type === "resized") { + return { type, protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, id: 1 }; + } + return { type, protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION }; + }); + await new Promise((resolve, reject) => { + events + .end( + values + .map((value) => encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, value)) + .join(""), + resolve, + ) + .once("error", reject); + }); + return true; +} + +async function waitForFile(file) { + try { + await readFile(file); + return; + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + await new Promise((resolve, reject) => { + const watcher = watch(path.dirname(file), (event, name) => { + if (name?.toString() !== path.basename(file)) return; + watcher.close(); + resolve(); + }); + watcher.once("error", reject); + void readFile(file).then( + () => { + watcher.close(); + resolve(); + return undefined; + }, + (error) => { + if (error.code !== "ENOENT") { + watcher.close(); + reject(error); + } + return undefined; + }, + ); + }); +} + +async function signal(id, execId, signalName) { + const { options } = CommandRuntimeLifecycleRequestSchema.parse( + JSON.parse(await readStream(process.stdin)), + ); + let pid; + try { + pid = Number(await readFile(execFile(id, execId, options), "utf8")); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + killGroup(pid, signalName); + await waitForProcessExit(pid); + await rm(execFile(id, execId, options), { force: true }); + if (options.signalHelperFailure === "error") throw new Error("fixture signal helper failed"); + if (options.signalHelperFailure === "hang") await new Promise(() => {}); +} + +async function waitForProcessExit(pid) { + const deadline = Date.now() + 1_000; + while (processExists(pid)) { + if (Date.now() >= deadline) throw new Error(`Fixture workload remained alive: ${pid}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function processExists(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error.code === "ESRCH") return false; + throw error; + } +} + +async function applyInspectBarrier(options) { + if (!options.inspectBarrierDirectory) return; + const next = path.join(options.inspectBarrierDirectory, "block-next-inspect"); + try { + await rm(next); + } catch (error) { + if (error.code === "ENOENT") return; + throw error; + } + await writeFile(path.join(options.inspectBarrierDirectory, "inspect-entered"), "entered"); + const release = path.join(options.inspectBarrierDirectory, "release-inspect"); + while (true) { + try { + await readFile(release); + return; + } catch (error) { + if (error.code !== "ENOENT") throw error; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + } +} + +async function readState(id, options) { + try { + return JSON.parse(await readFile(stateFile(id, options), "utf8")); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +function stateFile(id, options) { + return path.join(options.stateDirectory, `${key(id)}.json`); +} + +function execFile(id, execId, options) { + return path.join(options.stateDirectory, `${key(id)}-${execId}.pid`); +} + +function key(value) { + return createHash("sha256").update(value).digest("hex"); +} + +async function resolveCwd(root, relativeCwd) { + const canonicalRoot = await realpath(root); + const canonicalCwd = await realpath(path.resolve(canonicalRoot, relativeCwd ?? ".")); + const relative = path.relative(canonicalRoot, canonicalCwd); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error("Workspace cwd escapes its runtime root"); + } + return canonicalCwd; +} + +function killGroup(pid, signalName) { + try { + process.kill(-pid, signalName); + } catch { + process.kill(pid, signalName); + } +} + +function argument(flag) { + const index = process.argv.indexOf(flag, 2); + return index < 0 ? undefined : process.argv[index + 1]; +} + +function requireArgument(flag) { + const value = argument(flag); + if (!value) throw new Error(`${flag} is required`); + return value; +} + +async function readStream(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function writeJson(schema, value) { + process.stdout.write(encodeCommandRuntimeMessage(schema, value)); +} diff --git a/packages/server/package.json b/packages/server/package.json index cd3f7d681c..4f347d2a5c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -76,6 +76,7 @@ "@getpaseo/highlight": "0.3.1", "@getpaseo/protocol": "0.3.1", "@getpaseo/relay": "0.3.1", + "@getpaseo/workspace-runtime-contract": "0.3.1", "@isaacs/ttlcache": "^2.1.4", "@modelcontextprotocol/sdk": "^1.20.1", "@opencode-ai/sdk": "1.14.46", 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 5d3e1e7d24..684bd73cf5 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 @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { chmod, copyFile, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, expect, test } from "vitest"; @@ -15,6 +16,12 @@ const fixtureAgent = new URL( "../../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url, ); +const fixtureRuntime = fileURLToPath( + new URL("../../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url), +); +const helperExecutable = fileURLToPath( + new URL("../../workspace-helper/executable.mjs", import.meta.url), +); const posixDescribe = describe.runIf(process.platform !== "win32"); @@ -88,7 +95,7 @@ posixDescribe("ACP workspace terminal execution", () => { } }); - test.each(["local", "worktree"] as const)( + test.each(["local", "worktree", "fixture"] as const)( "discovers and launches an ACP provider through the selected %s workspace", async (selectedRuntimeId) => { const root = await mkdtemp(path.join(tmpdir(), "paseo-acp-provider-runtime-")); @@ -103,11 +110,24 @@ posixDescribe("ACP workspace terminal execution", () => { execFileSync("git", ["add", "."], { cwd }); execFileSync("git", ["commit", "-m", "fixture"], { cwd }); const runtimeIds = new Map(); + const fixtureStateDirectory = path.join(root, "fixture-state"); + await mkdir(fixtureStateDirectory); const service = createWorkspaceRuntimeService({ paseoHome: path.join(root, "home"), resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, persistRuntimeId: async (workspaceId, persistedRuntimeId) => runtimeIds.set(workspaceId, persistedRuntimeId), + externalRuntimes: + selectedRuntimeId === "fixture" + ? { + fixture: { + type: "command", + command: [process.execPath, fixtureRuntime], + helperCommand: [process.execPath, helperExecutable], + options: { stateDirectory: fixtureStateDirectory }, + }, + } + : undefined, ...lifecycleRecords(runtimeIds), }); await service.create({ @@ -115,7 +135,7 @@ posixDescribe("ACP workspace terminal execution", () => { runtimeId: selectedRuntimeId, project: { id: "project", source: { kind: "host-directory", path: cwd } }, placement: - selectedRuntimeId === "local" + selectedRuntimeId !== "worktree" ? { kind: "existing" } : { kind: "branch", branchName: "provider-worktree", baseRef: "main" }, }); diff --git a/packages/server/src/server/agent/providers/workspace/index.ts b/packages/server/src/server/agent/providers/workspace/index.ts index f64cd264c5..9431446c21 100644 --- a/packages/server/src/server/agent/providers/workspace/index.ts +++ b/packages/server/src/server/agent/providers/workspace/index.ts @@ -11,6 +11,7 @@ import type { WorkspaceProcess, WorkspaceProcessPurpose, } from "../../../workspace-runtime/index.js"; +import type { WorkspaceDirectory } from "../../../workspace-helper/index.js"; export interface ProviderPlacementPolicy { environment: @@ -64,7 +65,7 @@ export function rejectSelectedProviderWorkspaceFailure( } export interface MaterializedProviderStateFile { - /** Opaque runtime-local path passed only to the provider process. */ + /** Workspace-relative path passed only to the provider process. */ readonly path: string; /** Root-relative state path retained by the provider workspace authority. */ readonly statePath: string; @@ -86,7 +87,7 @@ export interface ProviderWorkspace { }): Promise<{ stdout: string; stderr: string }>; readWorkspaceText(path: string): Promise; writeWorkspaceText(path: string, content: string): Promise; - listState(path: string): ReturnType; + listState(path: string): Promise; readStateText(path: string): Promise; findStateFile(root: string, fileName: string): Promise; materializeStateFile(input: { @@ -169,19 +170,36 @@ export function bindProviderWorkspace(input: { }, async listState(statePath) { const relativePath = requireRelativeStatePath(statePath); - try { - return await input.runtime.homeFiles.list(relativePath); - } catch (error) { - if (isKnownMissingFileError(error)) throw new ProviderStateNotFoundError(relativePath); - throw error; + const node = await this.resolveExecutable("node"); + const script = [ + 'const f=require("fs/promises"),o=require("os"),p=require("path");', + "(async()=>{const root=await f.realpath(o.homedir()),relative=process.argv[1],target=p.resolve(root,relative),lexical=p.relative(root,target);", + 'if(lexical.startsWith("..")||p.isAbsolute(lexical))throw new Error("Provider state path escapes its root");', + 'let canonical;try{canonical=await f.realpath(target);}catch(error){if(error?.code==="ENOENT"){process.exitCode=44;return;}throw error;}const confined=p.relative(root,canonical);if(confined.startsWith("..")||p.isAbsolute(confined))throw new Error("Provider state symlink escapes its root");', + 'const entries=[];for(const name of await f.readdir(canonical)){const absolute=p.join(canonical,name),info=await f.stat(absolute);if(!info.isFile()&&!info.isDirectory())continue;entries.push({name,path:p.posix.join(relative.split(p.sep).join("/"),name),kind:info.isDirectory()?"directory":"file",size:info.size,modifiedAt:info.mtime.toISOString()});}process.stdout.write(JSON.stringify({path:relative,entries}));})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + ].join(""); + const result = await runProviderStateCommand(run, node, script, relativePath); + if (result.exit.code === 44) throw new ProviderStateNotFoundError(relativePath); + if (result.exit.code !== 0 || result.exit.signal !== null) { + throw new Error(`Provider state listing failed: ${result.stderr || result.exit.signal}`); } + return JSON.parse(result.stdout) as WorkspaceDirectory; }, async readStateText(path) { const statePath = requireRelativeStatePath(path); - const state = await input.runtime.homeFiles.stat(statePath); - if (state.status === "missing") throw new ProviderStateNotFoundError(statePath); - if (state.status === "error") throw new Error(state.error); - return collect((await input.runtime.homeFiles.read(statePath)).chunks); + const node = await this.resolveExecutable("node"); + const script = [ + 'const f=require("fs/promises"),o=require("os"),p=require("path");', + "(async()=>{const root=await f.realpath(o.homedir()),relative=process.argv[1],target=p.resolve(root,relative),lexical=p.relative(root,target);", + 'if(lexical.startsWith("..")||p.isAbsolute(lexical))throw new Error("Provider state path escapes its root");', + 'let canonical;try{canonical=await f.realpath(target);}catch(error){if(error?.code==="ENOENT"){process.exitCode=44;return;}throw error;}const confined=p.relative(root,canonical);if(confined.startsWith("..")||p.isAbsolute(confined))throw new Error("Provider state symlink escapes its root");process.stdout.write(await f.readFile(canonical));})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + ].join(""); + const result = await runProviderStateCommand(run, node, script, statePath); + if (result.exit.code === 44) throw new ProviderStateNotFoundError(statePath); + if (result.exit.code !== 0 || result.exit.signal !== null) { + throw new Error(`Provider state read failed: ${result.stderr || result.exit.signal}`); + } + return result.stdout; }, async findStateFile(root, fileName) { const stateRoot = requireRelativeStatePath(root); @@ -207,11 +225,11 @@ export function bindProviderWorkspace(input: { const safeName = stateFile.name.replace(/[^A-Za-z0-9._-]/g, "-"); const relativePath = `.paseo/provider-state/${randomUUID()}-${safeName}`; const script = [ - 'const fs=require("fs"),fsp=require("fs/promises"),os=require("os"),path=require("path"),stream=require("stream/promises");', - "(async()=>{const root=await fsp.realpath(os.homedir()),relative=process.argv[1],target=path.resolve(root,relative),parent=path.dirname(target);", + 'const fs=require("fs"),fsp=require("fs/promises"),path=require("path"),stream=require("stream/promises");', + "(async()=>{const root=await fsp.realpath('.'),relative=process.argv[1],target=path.resolve(root,relative),parent=path.dirname(target);", 'if(path.isAbsolute(relative)||relative.split(/[\\\\/]+/u).includes(".."))throw new Error("Provider state path must be root-relative");', 'await fsp.mkdir(parent,{recursive:true});const canonicalParent=await fsp.realpath(parent),rel=path.relative(root,canonicalParent);if(rel.startsWith("..")||path.isAbsolute(rel))throw new Error("Provider state path escapes its root");', - 'const handle=await fsp.open(target,"wx",0o600);try{await stream.pipeline(process.stdin,fs.createWriteStream(target,{fd:handle.fd,autoClose:false}));await handle.sync();}catch(error){await fsp.unlink(target).catch(()=>{});throw error;}finally{await handle.close();}process.stdout.write(target);})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', + 'const handle=await fsp.open(target,"wx",0o600);try{await stream.pipeline(process.stdin,fs.createWriteStream(target,{fd:handle.fd,autoClose:false}));await handle.sync();}catch(error){await fsp.unlink(target).catch(()=>{});throw error;}finally{await handle.close();}})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', ].join(""); const process = await run({ argv: [node, "-e", script, relativePath], @@ -221,19 +239,14 @@ export function bindProviderWorkspace(input: { const stderr = collect(process.stderr); try { await writeProcessInput(process, toStateContent(stateFile)); - const [runtimePath, diagnostics, exit] = await Promise.all([ - stdout, - stderr, - process.exited, - ]); + const [, diagnostics, exit] = await Promise.all([stdout, stderr, process.exited]); if (exit.code !== 0 || exit.signal !== null) { throw new Error( `Provider state materialization failed: ${diagnostics || exit.code || exit.signal}`, ); } - if (!runtimePath) throw new Error("Provider state materialization returned no path"); return { - path: runtimePath, + path: relativePath, statePath: relativePath, remove: () => this.removeStateFile(relativePath), }; @@ -246,8 +259,8 @@ export function bindProviderWorkspace(input: { const statePath = requireRelativeStatePath(path); const node = await this.resolveExecutable("node"); const script = [ - 'const f=require("fs/promises"),o=require("os"),p=require("path");', - "(async()=>{const root=await f.realpath(o.homedir()),relative=process.argv[1],target=p.resolve(root,relative),lexical=p.relative(root,target);", + 'const f=require("fs/promises"),p=require("path");', + "(async()=>{const root=await f.realpath('.'),relative=process.argv[1],target=p.resolve(root,relative),lexical=p.relative(root,target);", 'if(lexical.startsWith("..")||p.isAbsolute(lexical))throw new Error("Provider state path escapes its root");', 'let canonical;try{canonical=await f.realpath(target);}catch(error){if(error?.code==="ENOENT")return;throw error;}const resolved=p.relative(root,canonical);if(resolved.startsWith("..")||p.isAbsolute(resolved))throw new Error("Provider state symlink escapes its root");const info=await f.lstat(target);if(!info.isFile()&&!info.isSymbolicLink())throw new Error("Provider state removal requires a file");await f.unlink(target);})().catch(error=>{process.stderr.write(String(error));process.exitCode=1});', ].join(""); @@ -475,6 +488,29 @@ async function exitsWithin(process: WorkspaceProcess, timeoutMs: number): Promis } } +async function runProviderStateCommand( + launch: (input: ProviderWorkspaceLaunchInput) => Promise, + node: string, + script: string, + relativePath: string, +): Promise<{ + stdout: string; + stderr: string; + exit: { code: number | null; signal: NodeJS.Signals | null }; +}> { + const process = await launch({ + argv: [node, "-e", script, relativePath], + purpose: { kind: "provider-probe", provider: "provider-state" }, + }); + process.stdin.end(); + const [stdout, stderr, exit] = await Promise.all([ + collect(process.stdout), + collect(process.stderr), + process.exited, + ]); + return { stdout, stderr, exit }; +} + function requireRelativeStatePath(statePath: string): string { if ( !statePath || @@ -488,10 +524,6 @@ function requireRelativeStatePath(statePath: string): string { return nodePath.posix.normalize(statePath); } -function isKnownMissingFileError(error: unknown): boolean { - return error instanceof Error && /\b(?:ENOENT|ENOTDIR)\b/u.test(error.message); -} - function toStateContent(input: { content: string | Uint8Array | AsyncIterable; encoding?: "utf8" | "base64"; diff --git a/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts index dc58e7af34..a28f7758c6 100644 --- a/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts +++ b/packages/server/src/server/agent/providers/workspace/workspace.posix.test.ts @@ -1,6 +1,6 @@ import { once } from "node:events"; import { access, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; +import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; @@ -58,11 +58,13 @@ posixDescribe("provider workspace placement capability", () => { name: "large-image.bin", content: contents, }); - expect(await readFile(stateFile.path)).toEqual(contents); + expect(await readFile(path.join(cwd, stateFile.path))).toEqual(contents); expect(launches.flat().join(" ")).not.toContain(contents.toString("base64")); await stateFile.remove(); - await expect(access(stateFile.path)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(access(path.join(cwd, stateFile.path))).rejects.toMatchObject({ + code: "ENOENT", + }); } finally { await service.destroy("provider-state"); await rm(root, { recursive: true, force: true }); @@ -102,16 +104,11 @@ posixDescribe("provider workspace placement capability", () => { }); const outside = path.join(root, "outside.txt"); await writeFile(outside, "outside"); - const stateDirectory = path.join( - homedir(), - ".paseo", - "provider-state", - `removal-${process.pid}-${Date.now()}`, - ); + const stateDirectory = path.join(cwd, ".paseo", "provider-state"); await import("node:fs/promises").then((fs) => fs.mkdir(stateDirectory, { recursive: true })); const escapeLink = path.join(stateDirectory, "escape-link"); await symlink(outside, escapeLink); - const relativeLink = path.relative(homedir(), escapeLink); + const relativeLink = path.relative(cwd, escapeLink); try { await expect(workspace.removeStateFile(outside)).rejects.toThrow(/relative|outside/i); diff --git a/packages/server/src/server/config.test.ts b/packages/server/src/server/config.test.ts index c8a12c0569..f5d4eea94a 100644 --- a/packages/server/src/server/config.test.ts +++ b/packages/server/src/server/config.test.ts @@ -64,7 +64,7 @@ describe("server config", () => { const stateDirectory = path.join(paseoHome, "runtime-state"); await Promise.all([mkdir(source), mkdir(stateDirectory)]); const fixtureExecutable = fileURLToPath( - new URL("./test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url), + new URL("../../../fixture-workspace-runtime/src/index.mjs", import.meta.url), ); const configPath = path.join(paseoHome, "config.json"); await writeFile( @@ -94,6 +94,10 @@ describe("server config", () => { persistRuntimeId: async (workspaceId, runtimeId) => { runtimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, }); const createInput = { workspaceId: "configured-runtime", @@ -105,11 +109,14 @@ describe("server config", () => { await expect(service.create(createInput)).resolves.toEqual({ workspaceId: "configured-runtime", runtimeId: "fixture", + cwd: source, + hostVisiblePath: source, }); await expect( service.create({ ...createInput, workspaceId: "unknown-runtime", runtimeId: "unknown" }), ).rejects.toThrow("Workspace runtime is not registered: unknown"); await service.destroy("configured-runtime"); + expect(runtimeIds.has("configured-runtime")).toBe(false); }); test("resolves bundled web UI path from source-tree modules", () => { diff --git a/packages/server/src/server/test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs b/packages/server/src/server/test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs deleted file mode 100644 index 94b1567d3e..0000000000 --- a/packages/server/src/server/test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import { readFile, writeFile } from "node:fs/promises"; -import readline from "node:readline"; - -const [helper, counterPath, sharedRoot, ...args] = process.argv.slice(2); -if (!helper || !counterPath || !sharedRoot) { - throw new Error("helper, counter path, and shared root are required"); -} - -const rootIndex = args.indexOf("--root"); -const observedRoot = rootIndex < 0 ? null : args[rootIndex + 1]; -if (args[0] !== "watch" || observedRoot !== sharedRoot) { - await forward(); -} else { - const launch = Number(await readFile(counterPath, "utf8").catch(() => "0")) + 1; - await writeFile(counterPath, String(launch)); - if (launch === 2) await failSecondSubscription(); - else await forward(); -} - -async function forward() { - const child = spawn(process.execPath, [helper, ...args], { stdio: "inherit" }); - const exit = await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code, signal) => resolve({ code, signal })); - }); - if (exit.signal) process.kill(process.pid, exit.signal); - process.exitCode = exit.code ?? 1; -} - -async function failSecondSubscription() { - process.stdout.write(`${JSON.stringify({ protocolVersion: 1, type: "ready" })}\n`); - const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - let subscriptions = 0; - for await (const line of lines) { - const message = JSON.parse(line); - if (message.type === "subscribe") { - subscriptions += 1; - if (subscriptions === 2) { - process.exitCode = 41; - return; - } - process.stdout.write( - `${JSON.stringify({ protocolVersion: 1, type: "subscribed", subscriptionId: message.id })}\n`, - ); - } else if (message.type === "unsubscribe") { - process.stdout.write( - `${JSON.stringify({ protocolVersion: 1, type: "unsubscribed", subscriptionId: message.id })}\n`, - ); - } else if (message.type === "close") return; - } -} diff --git a/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs b/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs deleted file mode 100644 index e63d7a8b51..0000000000 --- a/packages/server/src/server/test-utils/fixtures/workspace-runtime-command-fixture.mjs +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { Socket } from "node:net"; -import { createInterface } from "node:readline"; -import * as pty from "node-pty"; - -const operation = process.argv - .slice(2) - .find((value) => - ["describe", "create", "inspect", "exec", "signal", "pause", "resume", "destroy"].includes( - value, - ), - ); -const workspaceId = argument("--workspace-id"); - -try { - if (operation === "describe") { - writeJson({ - protocolVersion: 1, - modes: argument("--modes") === "pipes" ? ["pipes"] : ["pipes", "pty"], - }); - } else if (!workspaceId) { - throw new Error("--workspace-id is required"); - } else if (operation === "exec") { - await execute(workspaceId); - } else if (operation === "signal") { - await signal(workspaceId, requireArgument("--exec-id"), requireArgument("--signal")); - } else { - const request = JSON.parse(await readStream(process.stdin)); - switch (operation) { - case "create": - { - const state = await create(workspaceId, request); - writeJson({ - protocolVersion: 1, - type: "state", - state, - placement: publicPlacement(state), - }); - } - break; - case "inspect": - writeJson({ - protocolVersion: 1, - type: "inspection", - inspection: await inspect(workspaceId, request.options), - }); - break; - case "pause": - await setLifecycle(workspaceId, request.options, "paused"); - writeJson({ protocolVersion: 1, type: "ok" }); - break; - case "resume": - { - const state = await setLifecycle(workspaceId, request.options, "ready"); - writeJson({ - protocolVersion: 1, - type: "state", - state, - placement: publicPlacement(state), - }); - } - break; - case "destroy": - await rm(stateFile(workspaceId, request.options), { force: true }); - writeJson({ protocolVersion: 1, type: "ok" }); - break; - default: - throw new Error(`Unknown operation: ${operation}`); - } - } -} catch (error) { - process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - process.exitCode = 1; -} - -async function create(id, request) { - const existing = await readState(id, request.options); - if (existing) return existing; - const root = request.input.project.source.path; - const state = { - workspaceId: id, - root, - revision: `fixture:${key(`${id}:${Date.now()}:${process.pid}`)}`, - executionDomainId: "fixture", - lifecycle: "ready", - }; - await mkdir(request.options.stateDirectory, { recursive: true }); - await writeFile(stateFile(id, request.options), JSON.stringify(state)); - return state; -} - -async function inspect(id, options) { - await applyInspectBarrier(options); - const state = await readState(id, options); - return state - ? { status: state.lifecycle, state, placement: publicPlacement(state) } - : { status: "missing" }; -} - -function publicPlacement(state) { - return { cwd: state.root, hostVisiblePath: state.root }; -} - -async function setLifecycle(id, options, lifecycle) { - const state = await readState(id, options); - if (!state) throw new Error(`Fixture workspace is missing: ${id}`); - const updated = { ...state, lifecycle }; - await writeFile(stateFile(id, options), JSON.stringify(updated)); - return updated; -} - -async function execute(id) { - const 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("spawn control is required"); - const envelope = JSON.parse(first.value); - const state = await readState(id, envelope.options); - if (!state) throw new Error(`Fixture workspace is missing: ${id}`); - if (envelope.options.recordLaunchInWorkspace !== false) { - await writeFile( - path.join(state.root, ".runtime-launch.json"), - JSON.stringify({ argv: process.argv, purpose: envelope.purpose }), - ); - } - if (envelope.stdio.kind === "pty") { - await executePty(id, envelope, iterator, control); - control.destroy(); - return; - } - const child = spawn(envelope.argv[0], envelope.argv.slice(1), { - cwd: path.resolve(state.root, envelope.cwd ?? "."), - env: envelope.env, - detached: true, - stdio: ["inherit", "inherit", "inherit"], - }); - await writeFile(execFile(id, envelope.execId, envelope.options), String(child.pid)); - let forwardedSignal = null; - const handlers = new Map(); - for (const signalName of ["SIGINT", "SIGTERM", "SIGHUP"]) { - const handler = () => { - forwardedSignal = signalName; - killGroup(child.pid, signalName); - }; - handlers.set(signalName, handler); - process.on(signalName, handler); - } - const exit = await new Promise((resolve, reject) => { - child.once("error", reject); - child.once("exit", (code, signalName) => resolve({ code, signal: signalName })); - }); - await rm(execFile(id, envelope.execId, envelope.options), { force: true }); - for (const [signalName, handler] of handlers) process.off(signalName, handler); - const signalName = forwardedSignal ?? exit.signal; - if (signalName) { - process.removeAllListeners(signalName); - process.kill(process.pid, signalName); - } else { - process.exitCode = exit.code ?? 1; - } -} - -async function executePty(id, envelope, controls, controlStream) { - const events = new Socket({ fd: 4, readable: false, writable: true }); - const child = pty.spawn(envelope.argv[0], envelope.argv.slice(1), { - cwd: path.resolve((await readState(id, envelope.options)).root, envelope.cwd ?? "."), - env: envelope.env, - name: envelope.stdio.term ?? "xterm-256color", - cols: envelope.stdio.cols, - rows: envelope.stdio.rows, - }); - await writeFile(execFile(id, envelope.execId, envelope.options), String(child.pid)); - if (envelope.options.closePtyControl) setTimeout(() => controlStream.destroy(), 100); - process.stdin.on("data", (data) => child.write(data.toString())); - child.onData((data) => process.stdout.write(data)); - if (envelope.options.invalidPtyEvent) { - setTimeout( - () => events.end(`${JSON.stringify({ protocolVersion: 1, type: "invalid" })}\n`), - 100, - ); - } - let requestedSignal = null; - void (async () => { - while (true) { - const next = await controls.next(); - if (next.done) return; - const control = JSON.parse(next.value); - if (control.type === "resize") { - child.resize(control.cols, control.rows); - events.write( - `${JSON.stringify({ protocolVersion: 1, type: "resized", id: control.id })}\n`, - ); - } else if (control.type === "signal") { - requestedSignal = control.signal; - child.kill(control.signal); - } else throw new Error(`Unexpected PTY control: ${control.type}`); - } - })().catch((error) => { - events.write( - `${JSON.stringify({ protocolVersion: 1, type: "error", message: error.message })}\n`, - ); - child.kill("SIGKILL"); - }); - const exit = await new Promise((resolve) => - child.onExit(({ exitCode, signal: signalNumber }) => - resolve({ exitCode, signal: signalNumber }), - ), - ); - await rm(execFile(id, envelope.execId, envelope.options), { force: true }); - process.stdin.destroy(); - if (envelope.options.invalidPtyEvent) return; - events.write(`${JSON.stringify({ protocolVersion: 1, type: "eof" })}\n`); - if (envelope.options.omitPtyExitEvent) { - events.end(); - return; - } - const exitEvent = `${JSON.stringify({ - protocolVersion: 1, - type: "exit", - code: requestedSignal || exit.signal ? null : exit.exitCode, - signal: requestedSignal, - })}\n`; - if (envelope.options.delayedPtyExitEvent) { - const writer = spawn( - process.execPath, - [ - "-e", - `setTimeout(()=>{require('node:fs').writeSync(4,${JSON.stringify(exitEvent)});},1000)`, - ], - { detached: true, stdio: ["ignore", "ignore", "ignore", "ignore", events] }, - ); - writer.unref(); - events.destroy(); - return; - } - events.end(exitEvent); -} - -async function signal(id, execId, signalName) { - const { options } = JSON.parse(await readStream(process.stdin)); - const pid = Number(await readFile(execFile(id, execId, options), "utf8")); - killGroup(pid, signalName); - if (options.signalHelperFailure === "error") throw new Error("fixture signal helper failed"); - if (options.signalHelperFailure === "hang") await new Promise(() => {}); -} - -async function applyInspectBarrier(options) { - if (!options.inspectBarrierDirectory) return; - const next = path.join(options.inspectBarrierDirectory, "block-next-inspect"); - try { - await rm(next); - } catch (error) { - if (error.code === "ENOENT") return; - throw error; - } - await writeFile(path.join(options.inspectBarrierDirectory, "inspect-entered"), "entered"); - const release = path.join(options.inspectBarrierDirectory, "release-inspect"); - while (true) { - try { - await readFile(release); - return; - } catch (error) { - if (error.code !== "ENOENT") throw error; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } -} - -async function readState(id, options) { - try { - return JSON.parse(await readFile(stateFile(id, options), "utf8")); - } catch (error) { - if (error.code === "ENOENT") return null; - throw error; - } -} - -function stateFile(id, options) { - return path.join(options.stateDirectory, `${key(id)}.json`); -} - -function execFile(id, execId, options) { - return path.join(options.stateDirectory, `${key(id)}-${execId}.pid`); -} - -function key(value) { - return createHash("sha256").update(value).digest("hex"); -} - -function killGroup(pid, signalName) { - try { - process.kill(-pid, signalName); - } catch { - process.kill(pid, signalName); - } -} - -function argument(flag) { - const index = process.argv.indexOf(flag, 2); - return index < 0 ? undefined : process.argv[index + 1]; -} - -function requireArgument(flag) { - const value = argument(flag); - if (!value) throw new Error(`${flag} is required`); - return value; -} - -async function readStream(stream) { - const chunks = []; - for await (const chunk of stream) chunks.push(Buffer.from(chunk)); - return Buffer.concat(chunks).toString("utf8"); -} - -function writeJson(value) { - process.stdout.write(`${JSON.stringify(value)}\n`); -} 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 2230a3429f..db2ccd87e4 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 @@ -1,5 +1,5 @@ import { execFileSync, spawn } from "node:child_process"; -import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,14 +11,11 @@ import { observeWorkspaceGit } from "./workspace-git-observation.js"; import { createWorkspaceRuntimeService } from "./workspace-runtime/index.js"; const fixtureExecutable = fileURLToPath( - new URL("./test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url), + new URL("../../../fixture-workspace-runtime/src/index.mjs", import.meta.url), ); const helperExecutable = fileURLToPath( new URL("./workspace-helper/executable.mjs", import.meta.url), ); -const sharedRebindHelperExecutable = fileURLToPath( - new URL("./test-utils/fixtures/workspace-helper-shared-rebind-fixture.mjs", import.meta.url), -); const cleanupRoots: string[] = []; function eventWithin(event: Promise, label: string): Promise { @@ -231,88 +228,6 @@ test("replayed common-ref observation survives runtime service reconstruction", await reconstructed.destroy(workspaceId); }, 20_000); -test("failed second common-ref rebind rolls back every staged observer before retry", async () => { - const root = await mkdtemp(path.join(tmpdir(), "paseo-git-common-rebind-")); - cleanupRoots.push(root); - const source = await createRepository(path.join(root, "source")); - const workspaceId = "common-rebind"; - const runtimeIds = new Map(); - const counterPath = path.join(root, "common-watch-launches"); - const commonRoot = await realpath(path.join(source, ".git")); - 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: { - fixture: { - type: "command", - command: [process.execPath, fixtureExecutable], - helperCommand: [ - process.execPath, - sharedRebindHelperExecutable, - helperExecutable, - counterPath, - commonRoot, - ], - options: { stateDirectory: path.join(root, "state"), recordLaunchInWorkspace: false }, - }, - }, - }); - await service.create({ - workspaceId, - runtimeId: "fixture", - project: { id: "common-rebind-project", source: { kind: "host-directory", path: source } }, - placement: { kind: "existing" }, - }); - let observeChanges = false; - let resolveFirst!: () => void; - let resolveSecond!: () => void; - const firstChanged = new Promise((resolve) => { - resolveFirst = resolve; - }); - const secondChanged = new Promise((resolve) => { - resolveSecond = resolve; - }); - const runtime = await service.bind(workspaceId); - const subscriptions = await Promise.all([ - observeWorkspaceGit(runtime, () => { - if (observeChanges) resolveFirst(); - }), - observeWorkspaceGit(runtime, () => { - if (observeChanges) resolveSecond(); - }), - ]); - expect(Number(await readFile(counterPath, "utf8"))).toBe(1); - - await service.pause(workspaceId); - await expect(service.resume(workspaceId)).rejects.toThrow( - "Workspace helper subscribe acknowledgement timed out", - ); - await expect(createBranchThroughRuntime(service, workspaceId, "during-recovery")).rejects.toThrow( - `Workspace runtime is recovering: ${workspaceId}`, - ); - expect(hasSharedRebindHelper(root)).toBe(false); - await service.resume(workspaceId); - - observeChanges = true; - await createBranchThroughRuntime(service, workspaceId, "after-common-rebind"); - await Promise.all([ - eventWithin(firstChanged, "first common-ref observer after retry"), - eventWithin(secondChanged, "second common-ref observer after retry"), - ]); - expect(Number(await readFile(counterPath, "utf8"))).toBe(4); - - await Promise.all(subscriptions.map((subscription) => subscription.unsubscribe())); - await service.destroy(workspaceId); - expect(hasSharedRebindHelper(root)).toBe(false); -}, 20_000); - test("selected sibling worktrees share common-ref fan-out without touching an unrelated workspace", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-fanout-")); cleanupRoots.push(root); @@ -430,7 +345,7 @@ test("selected sibling worktrees share common-ref fan-out without touching an un await service.destroy("unrelated"); }, 20_000); -test("concurrent sibling subscriptions own exactly one common-ref watcher until final unsubscribe", async () => { +test("sibling Git observations stay workspace-bound across pause, resume, and unsubscribe", async () => { const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-watcher-race-")); cleanupRoots.push(root); const source = await createRepository(path.join(root, "source")); @@ -462,47 +377,57 @@ test("concurrent sibling subscriptions own exactly one common-ref watcher until } const [runtimeA, runtimeB] = await Promise.all([service.bind("race-a"), service.bind("race-b")]); + let changesA = 0; + let changesB = 0; + let resolveA!: () => void; + let resolveB!: () => void; + const nextA = () => + new Promise((resolve) => { + resolveA = resolve; + }); + const nextB = () => + new Promise((resolve) => { + resolveB = resolve; + }); const subscriptions = await Promise.all([ - observeWorkspaceGit(runtimeA, () => undefined), - observeWorkspaceGit(runtimeB, () => undefined), + observeWorkspaceGit(runtimeA, () => { + changesA += 1; + resolveA?.(); + }), + observeWorkspaceGit(runtimeB, () => { + changesB += 1; + resolveB?.(); + }), ]); - expect(countHelperWatchers(root)).toBe(3); + let eventA = nextA(); + let eventB = nextB(); + await createBranchThroughRuntime(service, "race-b", "race-both-active"); + await Promise.all([eventWithin(eventA, "sibling A"), eventWithin(eventB, "sibling B")]); await service.pause("race-a"); - expect(countHelperWatchers(root)).toBe(2); + const pausedChangesA = changesA; + eventB = nextB(); + await createBranchThroughRuntime(service, "race-b", "race-a-paused"); + await eventWithin(eventB, "active sibling while A is paused"); + expect(changesA).toBe(pausedChangesA); + await service.resume("race-a"); - expect(countHelperWatchers(root)).toBe(3); + eventA = nextA(); + eventB = nextB(); + await createBranchThroughRuntime(service, "race-b", "race-a-resumed"); + await Promise.all([eventWithin(eventA, "resumed sibling A"), eventWithin(eventB, "sibling B")]); await subscriptions[0].unsubscribe(); - expect(countHelperWatchers(root)).toBe(2); + const unsubscribedChangesA = changesA; + eventB = nextB(); + await createBranchThroughRuntime(service, "race-b", "race-a-unsubscribed"); + await eventWithin(eventB, "remaining sibling B"); + expect(changesA).toBe(unsubscribedChangesA); await subscriptions[1].unsubscribe(); - expect(countHelperWatchers(root)).toBe(0); await Promise.all([service.destroy("race-a"), service.destroy("race-b")]); }, 20_000); -function countHelperWatchers(root: string): number { - const processes = execFileSync("ps", ["-axo", "command="], { encoding: "utf8" }); - return processes - .split("\n") - .filter( - (command) => - command.includes("workspace-helper/executable.mjs") && - command.includes(" watch ") && - command.includes(path.basename(root)), - ).length; -} - -function hasSharedRebindHelper(root: string): boolean { - return execFileSync("ps", ["-axo", "command="], { encoding: "utf8" }) - .split("\n") - .some( - (command) => - command.includes("workspace-helper-shared-rebind-fixture.mjs") && - command.includes(path.basename(root)), - ); -} - async function createBranchThroughRuntime( service: ReturnType, workspaceId: string, diff --git a/packages/server/src/server/workspace-helper/executable.mjs b/packages/server/src/server/workspace-helper/executable.mjs index 62e8626b99..d16b5407e8 100644 --- a/packages/server/src/server/workspace-helper/executable.mjs +++ b/packages/server/src/server/workspace-helper/executable.mjs @@ -3,7 +3,6 @@ import { randomUUID } from "node:crypto"; import { constants, watch } from "node:fs"; import { access, open, realpath, readdir, rename, stat, unlink } from "node:fs/promises"; import path from "node:path"; -import os from "node:os"; import readline from "node:readline"; const outsideMessage = "Access outside of workspace is not allowed"; @@ -15,18 +14,14 @@ const imageTypes = new Map([ [".webp", "image/webp"], [".svg", "image/svg+xml"], ]); -const argv = process.argv.slice(2); -const operation = argv.shift(); -const argument = (name, fallback) => { - const index = argv.indexOf(name); - return index < 0 ? fallback : argv[index + 1]; -}; -const configuredRoot = argument("--root"); -const root = configuredRoot === "@home" ? os.homedir() : configuredRoot; -const allowAbsolute = argv.includes("--allow-absolute"); +const operation = process.argv[2]; +let argumentsByName = new Map(); +const argument = (name, fallback) => argumentsByName.get(name) ?? fallback; +const root = await realpath("."); try { - if (!operation || !root) throw new Error("Usage: workspace-helper --root "); + if (!operation) throw new Error("Usage: workspace-helper "); + argumentsByName = parseArguments(operation, process.argv.slice(3)); if (operation === "fs-stat") print(await fileStat(root, argument("--path", "."))); else if (operation === "fs-list") print(await list(root, argument("--path", "."))); else if (operation === "fs-read") await read(root, argument("--path")); @@ -42,6 +37,31 @@ try { process.exitCode = 1; } +function parseArguments(command, argv) { + const allowed = new Set(allowedArguments(command)); + const parsed = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const name = argv[index]; + if (!allowed.has(name)) throw new Error(`Unknown workspace-helper argument: ${name}`); + const value = argv[index + 1]; + if (value === undefined) throw new Error(`${name} requires a value`); + if (parsed.has(name)) throw new Error(`Duplicate workspace-helper argument: ${name}`); + parsed.set(name, value); + } + return parsed; +} + +function allowedArguments(command) { + if (command === "fs-write") { + return ["--path", "--expected-modified-at", "--expected-revision"]; + } + if (command === "fs-stat" || command === "fs-list" || command === "fs-read") { + return ["--path"]; + } + if (command === "resolve-command") return ["--name"]; + return []; +} + async function resolveCommand(workspaceRoot, name) { if (!name) throw new Error("--name is required"); if (name.includes("/") || (process.platform === "win32" && name.includes("\\"))) { @@ -54,7 +74,10 @@ async function resolveCommand(workspaceRoot, name) { } } const searchPath = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"; - for (const directory of searchPath.split(path.delimiter)) { + for (const directory of new Set([ + path.dirname(process.execPath), + ...searchPath.split(path.delimiter), + ])) { const candidate = path.join(directory, name); try { await access(candidate, constants.X_OK); @@ -290,10 +313,8 @@ function isIgnored(relativePath, ignoredPaths) { async function resolveScoped(workspaceRoot, relativePath = ".") { const normalizedRoot = path.resolve(workspaceRoot); - if (path.isAbsolute(relativePath) && !allowAbsolute) throw new Error(outsideMessage); - const requested = path.isAbsolute(relativePath) - ? path.resolve(relativePath) - : path.resolve(normalizedRoot, relativePath); + if (path.isAbsolute(relativePath)) throw new Error(outsideMessage); + const requested = path.resolve(normalizedRoot, relativePath); if (!contains(normalizedRoot, requested)) throw new Error(outsideMessage); const canonicalRoot = await realpath(normalizedRoot); try { diff --git a/packages/server/src/server/workspace-helper/internal/client.ts b/packages/server/src/server/workspace-helper/internal/client.ts index 658572bb09..8a9fac9bcb 100644 --- a/packages/server/src/server/workspace-helper/internal/client.ts +++ b/packages/server/src/server/workspace-helper/internal/client.ts @@ -1,5 +1,5 @@ import type { WorkspaceFileContent, WorkspaceFileRead, WorkspaceWatchEvent } from "../index.js"; -import type { WorkspaceFilesOwner, WorkspaceHelperProcess } from "../integration/index.js"; +import type { WorkspaceFilesOwner, WorkspaceHelperProcess } from "./integration/index.js"; import { describeSchema, directorySchema, @@ -25,13 +25,11 @@ interface LogicalWatchSubscription { listener: (event: WorkspaceWatchEvent) => void; } -const WATCH_ACKNOWLEDGEMENT_TIMEOUT_MS = 750; +const WATCH_ACKNOWLEDGEMENT_TIMEOUT_MS = 3_000; const WATCH_CLOSE_TIMEOUT_MS = 500; const PROCESS_STOP_TIMEOUT_MS = 1_000; export function createClient(options: { - root: string; - allowAbsolutePaths?: boolean; launch(argv: readonly [string, ...string[]]): Promise; command: readonly [string, ...string[]]; }): WorkspaceFilesOwner { @@ -47,14 +45,7 @@ export function createClient(options: { const command = async (name: string, args: readonly string[] = []) => { if (closing) throw new Error("Workspace files client is closed"); - const child = await options.launch([ - ...options.command, - name, - "--root", - options.root, - ...(options.allowAbsolutePaths ? ["--allow-absolute"] : []), - ...args, - ]); + const child = await options.launch([...options.command, name, ...args]); processes.add(child); void child.exited.then( () => processes.delete(child), @@ -86,12 +77,13 @@ export function createClient(options: { return { files: { stat(path) { - return json("fs-stat", ["--path", path], fileStatSchema); + return json("fs-stat", ["--path", requireRelativePath(path)], fileStatSchema); }, list(path) { - return json("fs-list", ["--path", path], directorySchema); + return json("fs-list", ["--path", requireRelativePath(path)], directorySchema); }, async read(path) { + path = requireRelativePath(path); const metadata = await json("fs-stat", ["--path", path], fileStatSchema); if (metadata.status !== "ready") { throw new Error(metadata.status === "error" ? metadata.error : `File not found: ${path}`); @@ -108,7 +100,7 @@ export function createClient(options: { return { ...metadata, chunks } satisfies WorkspaceFileRead; }, async write(input) { - const args = ["--path", input.path]; + const args = ["--path", requireRelativePath(input.path)]; if (input.expectedModifiedAt) args.push("--expected-modified-at", input.expectedModifiedAt); if (input.expectedRevision) args.push("--expected-revision", input.expectedRevision); const child = await command("fs-write", args); @@ -133,14 +125,21 @@ export function createClient(options: { }, async subscribe(input, listener) { if (closing) throw new Error("Workspace files client is closed"); + const confinedInput = { + ...input, + paths: input.paths.map(requireRelativePath), + ...(input.ignoredPaths + ? { ignoredPaths: input.ignoredPaths.map(requireRelativePath) } + : {}), + }; const id = `subscription-${++nextSubscriptionId}`; - watchSubscriptions.set(id, { input, listener }); + watchSubscriptions.set(id, { input: confinedInput, listener }); let state: WatchState | null = null; try { state = await requireWatcher(); if (state.subscriptions.has(id)) return subscriptionFor(id); state.subscriptions.set(id, listener); - await sendSubscription(state, id, input); + await sendSubscription(state, id, confinedInput); } catch (error) { watchSubscriptions.delete(id); state?.subscriptions.delete(id); @@ -514,6 +513,20 @@ function helperError( return new Error(stderr.trim() || `Workspace helper failed: ${exit.code ?? exit.signal}`); } +function requireRelativePath(value: string): string { + const segments = value.split(/[\\/]/u); + if ( + !value || + value.startsWith("/") || + /^[A-Za-z]:\//u.test(value) || + value.includes("\\") || + segments.includes("..") + ) { + throw new Error(`Workspace helper path must be relative: ${value}`); + } + return value; +} + function onceDrain(stream: NodeJS.WritableStream): Promise { return new Promise((resolve, reject) => { stream.once("drain", resolve); diff --git a/packages/server/src/server/workspace-helper/integration/index.ts b/packages/server/src/server/workspace-helper/internal/integration/index.ts similarity index 83% rename from packages/server/src/server/workspace-helper/integration/index.ts rename to packages/server/src/server/workspace-helper/internal/integration/index.ts index 24f85c3da6..355354e586 100644 --- a/packages/server/src/server/workspace-helper/integration/index.ts +++ b/packages/server/src/server/workspace-helper/internal/integration/index.ts @@ -1,7 +1,7 @@ import type { Readable, Writable } from "node:stream"; -import type { WorkspaceFiles } from "../index.js"; -import { createClient } from "../internal/client.js"; +import type { WorkspaceFiles } from "../../index.js"; +import { createClient } from "../client.js"; export interface WorkspaceHelperProcess { stdin: Writable; @@ -20,8 +20,6 @@ export interface WorkspaceFilesOwner { /** @package Parent integration SPI. Normal callers consume WorkspaceFiles from the public module. */ export function bindWorkspaceHelper(options: { - root: string; - allowAbsolutePaths?: boolean; command: readonly [string, ...string[]]; launch(argv: readonly [string, ...string[]]): Promise; }): WorkspaceFilesOwner { diff --git a/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts b/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts index bcfc320918..8e3270b5c5 100644 --- a/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts +++ b/packages/server/src/server/workspace-helper/workspace-helper.posix.test.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test } from "vitest"; import type { WorkspaceWatchEvent } from "./index.js"; -import { bindWorkspaceHelper, type WorkspaceHelperProcess } from "./integration/index.js"; +import { bindWorkspaceHelper, type WorkspaceHelperProcess } from "./internal/integration/index.js"; const posixDescribe = describe.runIf(process.platform !== "win32"); const cleanupRoots: string[] = []; @@ -28,9 +28,8 @@ async function fixture() { const root = path.join(parent, "workspace"); await mkdir(root); const client = bindWorkspaceHelper({ - root, command: [process.execPath, workspaceHelper], - launch: launchTestProcess, + launch: (argv) => launchTestProcess(argv, root), }); return { client, parent, root }; } @@ -42,6 +41,18 @@ async function collect(chunks: AsyncIterable): Promise { } posixDescribe("workspace-helper public capability", () => { + test("client sends only workspace-relative helper paths", async () => { + const { client } = await fixture(); + expect(() => client.files.stat("/tmp/outside")).toThrow( + "Workspace helper path must be relative", + ); + expect(() => client.files.list("../outside")).toThrow("Workspace helper path must be relative"); + await expect( + client.files.subscribe({ paths: ["C:/outside"] }, () => undefined), + ).rejects.toThrow("Workspace helper path must be relative"); + await client.close(); + }); + test("watch rejects a second root authority supplied by a subscriber", async () => { const parent = await mkdtemp(path.join(tmpdir(), "paseo-workspace-helper-root-")); cleanupRoots.push(parent); @@ -50,7 +61,8 @@ posixDescribe("workspace-helper public capability", () => { await mkdir(trustedRoot); await mkdir(untrustedRoot); await writeFile(path.join(untrustedRoot, "outside.txt"), "outside\n"); - const child = spawn(process.execPath, [workspaceHelper, "watch", "--root", trustedRoot], { + const child = spawn(process.execPath, [workspaceHelper, "watch"], { + cwd: trustedRoot, stdio: ["pipe", "pipe", "pipe"], }); const stdout = collect(child.stdout); @@ -133,10 +145,12 @@ posixDescribe("workspace-helper public capability", () => { resolveSecondSubscription = resolve; }); const client = bindWorkspaceHelper({ - root, command: [process.execPath, workspaceHelper], launch: async (argv) => { - const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(argv[0], argv.slice(1), { + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + }); if (argv.includes("watch")) { watcherChildren.push(child); if (watcherChildren.length === 2) { @@ -173,10 +187,12 @@ posixDescribe("workspace-helper public capability", () => { const children: ReturnType[] = []; const exits: WorkspaceHelperProcess["exited"][] = []; const client = bindWorkspaceHelper({ - root: parent, command: [process.execPath, adversarialHelper, "endless-read"], launch: async (argv): Promise => { - const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(argv[0], argv.slice(1), { + cwd: parent, + stdio: ["pipe", "pipe", "pipe"], + }); children.push(child); const exited = new Promise>((resolve, reject) => { child.once("error", reject); @@ -340,10 +356,12 @@ async function recordedClient(mode: string) { const exits: WorkspaceHelperProcess["exited"][] = []; const pids: number[] = []; const client = bindWorkspaceHelper({ - root, command: [process.execPath, adversarialHelper, mode], launch: async (argv): Promise => { - const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(argv[0], argv.slice(1), { + cwd: root, + stdio: ["pipe", "pipe", "pipe"], + }); const exited = new Promise>((resolve, reject) => { child.once("error", reject); child.once("exit", (code, signal) => resolve({ code, signal })); @@ -374,8 +392,9 @@ async function recordedClient(mode: string) { async function launchTestProcess( argv: readonly [string, ...string[]], + cwd: string, ): Promise { - const child = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"] }); + const child = spawn(argv[0], argv.slice(1), { cwd, stdio: ["pipe", "pipe", "pipe"] }); return childProcess(child); } 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 9b619a2bc6..5e25d44be8 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 @@ -4,6 +4,17 @@ import { constants } from "node:os"; import type { Readable, Writable } from "node:stream"; import { StringDecoder } from "node:string_decoder"; +import { + COMMAND_RUNTIME_PROTOCOL_VERSION, + CommandRuntimeControlSchema, + CommandRuntimeDescribeResponseSchema, + CommandRuntimeLifecycleRequestSchema, + CommandRuntimeLifecycleResponseSchema, + createCommandRuntimeProcessEventDecoder, + encodeCommandRuntimeMessage, + type CommandRuntimeProcessEvent, +} from "@getpaseo/workspace-runtime-contract"; + import type { WorkspaceDriverCreateInput, WorkspaceDriverInspection, @@ -13,12 +24,6 @@ import type { WorkspacePtyProcess, WorkspaceRuntimeDriver, } from "../../drivers/index.js"; -import { - CommandRuntimeDescribeResponseSchema, - CommandRuntimeLifecycleResponseSchema, - CommandRuntimePtyEventSchema, -} from "./protocol.js"; - const COMMAND_RUNTIME_CLEANUP_TIMEOUT_MS = 1_000; export interface CommandRuntimeConfig { @@ -36,7 +41,9 @@ export function createCommandRuntime( function describe(): Promise> { described ??= runCommand(["describe"], undefined).then((output) => { - const description = CommandRuntimeDescribeResponseSchema.parse(JSON.parse(output)); + const value: unknown = JSON.parse(output); + assertProtocolVersion(runtimeId, value); + const description = CommandRuntimeDescribeResponseSchema.parse(value); if (!description.modes.includes("pipes")) { throw new Error(`Workspace runtime ${runtimeId} does not support pipes`); } @@ -54,9 +61,15 @@ export function createCommandRuntime( await describe(); const output = await runCommand( [operation, "--workspace-id", workspaceId], - JSON.stringify({ protocolVersion: 1, input, options: config.options ?? {} }), + encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + input, + options: config.options ?? {}, + }), ); - return CommandRuntimeLifecycleResponseSchema.parse(JSON.parse(output)); + const value: unknown = JSON.parse(output); + assertProtocolVersion(runtimeId, value); + return CommandRuntimeLifecycleResponseSchema.parse(value); } return { @@ -109,9 +122,15 @@ export function createCommandRuntime( if (!supportsReconciliation) return; const output = await runCommand( ["reconcile"], - JSON.stringify({ protocolVersion: 1, workspaceIds, options: config.options ?? {} }), + encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + workspaceIds, + options: config.options ?? {}, + }), ); - const response = CommandRuntimeLifecycleResponseSchema.parse(JSON.parse(output)); + const value: unknown = JSON.parse(output); + assertProtocolVersion(runtimeId, value); + const response = CommandRuntimeLifecycleResponseSchema.parse(value); if (response.type !== "ok") throw new Error(`Invalid reconcile response from ${runtimeId}`); }, }; @@ -123,7 +142,7 @@ export function createCommandRuntime( stdio: ["pipe", "pipe", "pipe"], }); if (stdin === undefined) child.stdin.end(); - else child.stdin.end(`${stdin}\n`); + else child.stdin.end(stdin); const [stdout, stderr, exit] = await Promise.all([ collect(child.stdout), collect(child.stderr), @@ -151,6 +170,17 @@ function commandReady( return { state, placement }; } +function assertProtocolVersion(runtimeId: string, value: unknown): void { + const version = + typeof value === "object" && value !== null && "protocolVersion" in value + ? value.protocolVersion + : undefined; + if (version === COMMAND_RUNTIME_PROTOCOL_VERSION) return; + throw new Error( + `Workspace runtime ${runtimeId} uses unsupported command protocol version ${String(version)}; expected ${COMMAND_RUNTIME_PROTOCOL_VERSION}`, + ); +} + function spawnCommandPty( runtimeId: string, command: readonly [string, ...string[]], @@ -179,7 +209,6 @@ function spawnCommandPty( let resizeTimeout: ReturnType | null = null; let pendingWrites = ""; let settled = false; - let sawEof = false; let workloadExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; let eventsEnded = false; let wrapperClosed = false; @@ -204,10 +233,11 @@ function spawnCommandPty( child.stderr.on("data", (chunk: Buffer | string) => { stderr += chunk.toString(); }); - void readNdjson(events, (value) => { - const event = CommandRuntimePtyEventSchema.parse(value); + void readProcessEvents(events, "pty", (event) => { + if (event.type === "started") { + return; + } if (event.type === "eof") { - sawEof = true; return; } if (event.type === "resized") { @@ -225,18 +255,7 @@ function spawnCommandPty( failPty(new Error(`Workspace runtime ${runtimeId} PTY failed: ${event.message}`)); return; } - if (workloadExit) { - throw new Error(`Workspace runtime ${runtimeId} returned duplicate PTY exit events`); - } - const signal = event.signal; - let parsedSignal: NodeJS.Signals | null = null; - if (signal !== null) { - if (!isNodeSignal(signal)) { - throw new Error(`Workspace runtime ${runtimeId} returned an invalid signal: ${signal}`); - } - parsedSignal = signal; - } - workloadExit = { code: event.code, signal: parsedSignal }; + workloadExit = { code: event.code, signal: parseSignal(runtimeId, event.signal) }; child.stdin.end(); control.end(); }).then( @@ -261,17 +280,19 @@ function spawnCommandPty( resolveWrapperClosed(); finishFromAuthoritativeExit(); }); - writeControl({ - type: "spawn", - protocolVersion: 1, - argv: input.argv, - cwd: input.cwd, - env: input.env, - purpose: input.purpose, - options, - execId, - stdio: input.stdio, - }); + writeControl( + CommandRuntimeControlSchema.parse({ + type: "spawn", + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + argv: input.argv, + cwd: input.cwd, + env: input.env, + purpose: input.purpose, + options, + execId, + stdio: input.stdio, + }), + ); return { kind: "pty", @@ -289,9 +310,16 @@ function spawnCommandPty( else pendingWrites += data; }, resize(cols, rows) { + const controlValue = CommandRuntimeControlSchema.parse({ + type: "resize", + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + id: resizeId + 1, + cols, + rows, + }); resizeId += 1; pendingResizeId = resizeId; - writeControl({ type: "resize", protocolVersion: 1, id: resizeId, cols, rows }); + writeControl(controlValue); if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout( () => failPty(new Error("PTY resize acknowledgement timed out")), @@ -301,7 +329,13 @@ function spawnCommandPty( exited, kill(signal = "SIGTERM") { if (settled || failureCleanup) return; - writeControl({ type: "signal", protocolVersion: 1, signal }); + writeControl( + CommandRuntimeControlSchema.parse({ + type: "signal", + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + signal, + }), + ); }, }; @@ -316,7 +350,7 @@ function spawnCommandPty( function writeControl(value: unknown): void { try { - control.write(`${JSON.stringify(value)}\n`); + control.write(encodeCommandRuntimeMessage(CommandRuntimeControlSchema, value)); } catch (error) { failPty(error); } @@ -324,7 +358,7 @@ function spawnCommandPty( function finishFromAuthoritativeExit(): void { if (settled || failureCleanup || !eventsEnded || !wrapperClosed) return; - if (!workloadExit || !sawEof) { + if (!workloadExit) { const detail = stderr.trim() || wrapperExit?.signal || wrapperExit?.code || "unknown"; failPty( new Error( @@ -347,25 +381,26 @@ function spawnCommandPty( const failure = error instanceof Error ? error : new Error(String(error)); failureCleanup = (async () => { let cleanupError: unknown; - if (!wrapperClosed) { - try { - await forceKillCommandRuntime( - command, - input.workspaceId, - execId, - options, - child, - wrapperClosedPromise, - ); - } catch (cleanupFailure) { - cleanupError = cleanupFailure; - } + try { + await forceKillCommandRuntime( + command, + input.workspaceId, + execId, + options, + child, + wrapperClosedPromise, + ); + } catch (cleanupFailure) { + cleanupError = cleanupFailure; } settled = true; const cleanupDetail = cleanupError instanceof Error ? `; cleanup failed: ${cleanupError.message}` : ""; + const wrapperDetail = stderr.trim() ? `; wrapper: ${stderr.trim()}` : ""; rejectExit( - new Error(`Workspace runtime ${runtimeId} PTY failed: ${failure.message}${cleanupDetail}`), + new Error( + `Workspace runtime ${runtimeId} PTY failed: ${failure.message}${wrapperDetail}${cleanupDetail}`, + ), ); })(); } @@ -408,7 +443,12 @@ async function forceKillCommandRuntime( ), ); }); - signalCommand.stdin.end(JSON.stringify({ protocolVersion: 1, options })); + signalCommand.stdin.end( + encodeCommandRuntimeMessage(CommandRuntimeLifecycleRequestSchema, { + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, + options, + }), + ); const helper = await waitBounded(signalResult, COMMAND_RUNTIME_CLEANUP_TIMEOUT_MS); let cleanupError: Error | null = null; if (helper.timedOut) { @@ -446,19 +486,26 @@ function isNodeSignal(value: string): value is NodeJS.Signals { return value in constants.signals; } -async function readNdjson(stream: Readable, receive: (value: unknown) => void): Promise { - let pending = ""; +function parseSignal(runtimeId: string, signal: string | null): NodeJS.Signals | null { + if (signal === null) return null; + if (!isNodeSignal(signal)) { + throw new Error(`Workspace runtime ${runtimeId} returned an invalid signal: ${signal}`); + } + return signal; +} + +async function readProcessEvents( + stream: Readable, + mode: "pipes" | "pty", + receive: (value: CommandRuntimeProcessEvent) => void, +): Promise { + const decoder = createCommandRuntimeProcessEventDecoder(mode); for await (const chunk of stream) { - pending += chunk.toString(); - let newline = pending.indexOf("\n"); - while (newline >= 0) { - const line = pending.slice(0, newline); - pending = pending.slice(newline + 1); - if (line) receive(JSON.parse(line)); - newline = pending.indexOf("\n"); + for (const value of decoder.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) { + receive(value); } } - if (pending.trim()) receive(JSON.parse(pending)); + decoder.finish(); } function spawnCommandProcess( @@ -474,17 +521,74 @@ function spawnCommandProcess( { env: commandEnvironment(), shell: false, - stdio: ["pipe", "pipe", "pipe", "pipe"], + stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], }, ); const metadata = child.stdio[3] as Writable; - metadata.on("error", () => { - // The exec process owns the actionable spawn error through `exited`. + const events = child.stdio[4] as Readable; + let stderr = ""; + let signalable = false; + let pendingSignal: NodeJS.Signals | null = null; + let workloadExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let eventsEnded = false; + let wrapperClosed = false; + let wrapperExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; + let settled = false; + let cleanup: Promise | null = null; + let resolveWrapperClosed!: () => void; + const wrapperClosedPromise = new Promise((resolve) => { + resolveWrapperClosed = resolve; + }); + let resolveExit!: (exit: { code: number | null; signal: NodeJS.Signals | null }) => void; + let rejectExit!: (error: Error) => void; + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + resolveExit = resolve; + rejectExit = reject; + }, + ); + exited.catch(() => undefined); + child.stderr.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + void readProcessEvents(events, "pipes", (event) => { + if (event.type === "started") { + signalable = true; + if (pendingSignal) deliverSignal(pendingSignal); + return; + } + if (event.type === "eof") { + return; + } + if (event.type === "error") { + fail(new Error(event.message)); + return; + } + if (event.type === "resized") return; + workloadExit = { code: event.code, signal: parseSignal(runtimeId, event.signal) }; + }).then( + () => { + eventsEnded = true; + finish(); + return undefined; + }, + (error) => { + fail(error); + return undefined; + }, + ); + metadata.once("error", fail); + child.once("error", (error) => fail(new Error(`exec failed: ${error.message}`))); + child.once("exit", (code, signal) => { + wrapperClosed = true; + wrapperExit = { code, signal }; + resolveWrapperClosed(); + finish(); }); metadata.end( - `${JSON.stringify({ + encodeCommandRuntimeMessage(CommandRuntimeControlSchema, { type: "spawn", - protocolVersion: 1, + protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, argv: input.argv, cwd: input.cwd, env: input.env, @@ -492,15 +596,7 @@ function spawnCommandProcess( options, execId, stdio: input.stdio, - })}\n`, - ); - const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( - (resolve, reject) => { - child.once("error", (error) => { - reject(new Error(`Workspace runtime ${runtimeId} exec failed: ${error.message}`)); - }); - child.once("exit", (code, signal) => resolve({ code, signal })); - }, + }), ); return { @@ -510,22 +606,71 @@ function spawnCommandProcess( stderr: child.stderr, exited, kill(signal = "SIGTERM") { - if (child.exitCode !== null || child.signalCode !== null) return; - if (signal === "SIGKILL") { - void forceKillCommandRuntime( + if (settled || cleanup) return; + pendingSignal = signal; + if (signalable) deliverSignal(signal); + }, + }; + + function deliverSignal(signal: NodeJS.Signals): void { + pendingSignal = null; + if (signal === "SIGKILL") { + void forceKillCommandRuntime( + command, + input.workspaceId, + execId, + options, + child, + exited.then( + () => undefined, + () => undefined, + ), + ).catch(() => undefined); + } else child.kill(signal); + } + + function finish(): void { + if (settled || cleanup || !eventsEnded || !wrapperClosed) return; + if (!workloadExit) { + const detail = stderr.trim() || wrapperExit?.signal || wrapperExit?.code || "unknown"; + fail(new Error(`pipes wrapper ended without a valid fd4 exit event (${String(detail)})`)); + return; + } + settled = true; + resolveExit(workloadExit); + } + + function fail(error: unknown): void { + if (settled || cleanup) return; + metadata.destroy(); + events.destroy(); + child.stdin.destroy(); + const failure = error instanceof Error ? error : new Error(String(error)); + cleanup = (async () => { + let cleanupError: unknown; + try { + await forceKillCommandRuntime( command, input.workspaceId, execId, options, child, - exited.then( - () => undefined, - () => undefined, - ), - ).catch(() => undefined); - } else child.kill(signal); - }, - }; + wrapperClosedPromise, + ); + } catch (caught) { + cleanupError = caught; + } + settled = true; + const detail = + cleanupError instanceof Error ? `; cleanup failed: ${cleanupError.message}` : ""; + const wrapperDetail = stderr.trim() ? `; wrapper: ${stderr.trim()}` : ""; + rejectExit( + new Error( + `Workspace runtime ${runtimeId} pipes failed: ${failure.message}${wrapperDetail}${detail}`, + ), + ); + })(); + } } async function collect(stream: Readable): Promise { diff --git a/packages/server/src/server/workspace-runtime/command/internal/protocol.ts b/packages/server/src/server/workspace-runtime/command/internal/protocol.ts deleted file mode 100644 index c89fa58f47..0000000000 --- a/packages/server/src/server/workspace-runtime/command/internal/protocol.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { z } from "zod"; - -export const CommandRuntimeStateSchema = z.object({ - workspaceId: z.string(), - root: z.string(), - revision: z.string(), - executionDomainId: z.string(), - lifecycle: z.enum(["ready", "paused"]), - lifecycleEnvironment: z.record(z.string(), z.string()).optional(), -}); - -export const CommandRuntimePlacementSchema = z.object({ - cwd: z.string(), - hostVisiblePath: z.string().optional(), -}); - -export const CommandRuntimeInspectionSchema = z.discriminatedUnion("status", [ - z.object({ status: z.literal("missing") }), - z.object({ - status: z.literal("paused"), - state: CommandRuntimeStateSchema, - // COMPAT(command-runtime-placement): older v1 runtimes parse, then fail closed at selection. - placement: CommandRuntimePlacementSchema.optional(), - }), - z.object({ - status: z.literal("ready"), - state: CommandRuntimeStateSchema, - // COMPAT(command-runtime-placement): older v1 runtimes parse, then fail closed at selection. - placement: CommandRuntimePlacementSchema.optional(), - }), - z.object({ status: z.literal("error"), message: z.string() }), -]); - -export const CommandRuntimeLifecycleResponseSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("state"), - protocolVersion: z.literal(1), - state: CommandRuntimeStateSchema, - // COMPAT(command-runtime-placement): older v1 runtimes parse, then fail closed at selection. - placement: CommandRuntimePlacementSchema.optional(), - }), - z.object({ - type: z.literal("inspection"), - protocolVersion: z.literal(1), - inspection: CommandRuntimeInspectionSchema, - }), - z.object({ type: z.literal("ok"), protocolVersion: z.literal(1) }), -]); - -export const CommandRuntimeDescribeResponseSchema = z.object({ - protocolVersion: z.literal(1), - modes: z.array(z.enum(["pipes", "pty"])), - reconcile: z.boolean().optional().default(false), -}); - -export const CommandRuntimePtyEventSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("eof"), protocolVersion: z.literal(1) }), - z.object({ - type: z.literal("resized"), - protocolVersion: z.literal(1), - id: z.number().int().nonnegative(), - }), - z.object({ - type: z.literal("exit"), - protocolVersion: z.literal(1), - code: z.number().int().nullable(), - signal: z.string().nullable(), - }), - z.object({ - type: z.literal("error"), - protocolVersion: z.literal(1), - message: z.string(), - }), -]); diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index 37a0795d49..6878a9bf22 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -29,9 +29,6 @@ export interface WorkspaceDriverCreateInput { export interface WorkspaceDriverState { workspaceId: WorkspaceId; - root: string; - revision: string; - executionDomainId: string; lifecycle: "ready" | "paused"; lifecycleEnvironment?: Readonly>; } @@ -102,6 +99,10 @@ export interface WorkspaceRuntimeDriver { create(input: WorkspaceDriverCreateInput): Promise; inspect(workspaceId: WorkspaceId): Promise; spawn(input: WorkspaceDriverSpawnInput): Promise; + observeGit?( + workspaceId: WorkspaceId, + listener: () => void, + ): Promise<{ unsubscribe(): Promise }>; pause(workspaceId: WorkspaceId): Promise; resume(workspaceId: WorkspaceId): Promise; destroy(workspaceId: WorkspaceId): Promise; diff --git a/packages/server/src/server/workspace-runtime/git-observation/integration.ts b/packages/server/src/server/workspace-runtime/git-observation/integration.ts deleted file mode 100644 index cf05362e72..0000000000 --- a/packages/server/src/server/workspace-runtime/git-observation/integration.ts +++ /dev/null @@ -1,445 +0,0 @@ -import nodePath from "node:path"; - -import type { BoundWorkspaceRuntime } from "../index.js"; -import type { WorkspaceRuntimeDriver, WorkspaceDriverProcess } from "../drivers/index.js"; -import type { WorkspaceFilesOwner } from "../../workspace-helper/integration/index.js"; -import { bindWorkspaceHelper } from "../../workspace-helper/integration/index.js"; -import type { WorkspaceFilesSubscription } from "../../workspace-helper/index.js"; -import { registerGitCommonObservationCapability } from "./internal/capability.js"; - -const MAX_DISCOVERY_OUTPUT_BYTES = 64 * 1024; - -interface LogicalObservation { - workspaceId: string; - driver: WorkspaceRuntimeDriver; - listener: () => void; - scope: SharedScope; -} - -interface PhysicalOwner { - workspaceId: string; - client: WorkspaceFilesOwner; - bindings: Map; -} - -interface SharedScope { - key: string; - root: string; - observations: Set; - owner: PhysicalOwner | null; - tail: Promise; -} - -export interface ObservationRebindTransaction { - commit(): Promise; - rollback(): Promise; -} - -export interface GitCommonObservationCoordinator { - bind(runtime: BoundWorkspaceRuntime, workspaceId: string, driver: WorkspaceRuntimeDriver): void; - pause(workspaceId: string): Promise; - stageResume(workspaceId: string): Promise; - destroy(workspaceId: string): Promise; -} - -export function createGitCommonObservationCoordinator(options: { - launchHelper( - driver: WorkspaceRuntimeDriver, - workspaceId: string, - argv: readonly [string, ...string[]], - ): Promise; - runGit( - driver: WorkspaceRuntimeDriver, - workspaceId: string, - argv: readonly [string, ...string[]], - ): Promise; - isUnavailable(workspaceId: string): boolean; -}): GitCommonObservationCoordinator { - const scopes = new Map(); - - return { - bind(runtime, workspaceId, driver) { - registerGitCommonObservationCapability(runtime, { - observe: (listener) => observe(workspaceId, driver, listener), - }); - }, - async pause(workspaceId) { - for (const scope of relevantScopes(workspaceId)) { - await withScope(scope, () => pauseInScope(scope, workspaceId)); - } - }, - async stageResume(workspaceId) { - const locked = await lockScopes(relevantScopes(workspaceId)); - const transactions: ObservationRebindTransaction[] = []; - try { - for (const scope of locked.scopes) { - transactions.push(await stageScopeResume(scope, workspaceId)); - } - } catch (error) { - await Promise.allSettled(transactions.map((transaction) => transaction.rollback())); - locked.release(); - throw error; - } - return finishWithRelease(combineTransactions(transactions), locked.release); - }, - async destroy(workspaceId) { - for (const scope of relevantScopes(workspaceId)) { - await withScope(scope, async () => { - await pauseInScope(scope, workspaceId); - const owned = [...scope.observations].filter( - (observation) => observation.workspaceId === workspaceId, - ); - for (const observation of owned) removeLogicalObservation(observation); - await closeEmptyScope(scope); - }); - } - }, - }; - - function relevantScopes(workspaceId: string): SharedScope[] { - return [...scopes.values()].filter( - (scope) => - scope.owner?.workspaceId === workspaceId || - [...scope.observations].some((observation) => observation.workspaceId === workspaceId), - ); - } - - async function observe( - workspaceId: string, - driver: WorkspaceRuntimeDriver, - listener: () => void, - ): Promise<{ unsubscribe(): Promise }> { - const inspection = await driver.inspect(workspaceId); - if (inspection.status !== "ready") { - throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); - } - const root = await discoverCommonGitDirectory(driver, workspaceId); - if (!root) return { unsubscribe: async () => {} }; - assertRuntimeAbsolutePath(root); - const key = `${inspection.state.executionDomainId}\0${root}`; - const scope = scopes.get(key) ?? { - key, - root, - observations: new Set(), - owner: null, - tail: Promise.resolve(), - }; - scopes.set(key, scope); - const logical: LogicalObservation = { workspaceId, driver, listener, scope }; - await withScope(scope, async () => { - scope.observations.add(logical); - try { - if (!scope.owner) { - const staged = await stageReplacementOwner(scope, workspaceId); - await staged.commit(); - } else { - const staged = await stageBindings(scope.owner, [logical]); - await staged.commit(); - } - } catch (error) { - removeLogicalObservation(logical); - await closeEmptyScope(scope); - throw error; - } - }); - let active = true; - return { - async unsubscribe() { - if (!active) return; - active = false; - await withScope(scope, async () => { - const binding = scope.owner?.bindings.get(logical); - scope.owner?.bindings.delete(logical); - await binding?.unsubscribe(); - removeLogicalObservation(logical); - await closeEmptyScope(scope); - }); - }, - }; - } - - async function pauseInScope(scope: SharedScope, workspaceId: string): Promise { - if (scope.owner?.workspaceId === workspaceId) { - const candidates = readyObservations(scope); - if (candidates.length === 0) { - await closeOwner(scope); - return; - } - const staged = await stageReplacementOwner(scope); - await staged.commit(); - return; - } - if (!scope.owner) return; - const owned = [...scope.owner.bindings].filter( - ([observation]) => observation.workspaceId === workspaceId, - ); - for (const [observation, binding] of owned) { - scope.owner.bindings.delete(observation); - await binding.unsubscribe(); - } - } - - async function stageScopeResume( - scope: SharedScope, - workspaceId: string, - ): Promise { - const missing = [...scope.observations].filter( - (observation) => - observation.workspaceId === workspaceId && !scope.owner?.bindings.has(observation), - ); - if (missing.length === 0) return noOpTransaction(); - if (!scope.owner) return stageReplacementOwner(scope, workspaceId); - return stageBindings(scope.owner, missing); - } - - async function stageReplacementOwner( - scope: SharedScope, - allowedWorkspaceId?: string, - ): Promise { - const candidates = readyObservations(scope, allowedWorkspaceId); - const selected = candidates[0]; - if (!selected) throw new Error("Git common observation has no ready workspace owner"); - const client = bindWorkspaceHelper({ - root: scope.root, - command: selected.driver.workspaceHelperCommand, - launch: async (argv) => { - const process = await options.launchHelper(selected.driver, selected.workspaceId, argv); - if (process.kind !== "pipes") { - throw new Error("Git common observation helper requires pipe mode"); - } - return process; - }, - }); - const owner: PhysicalOwner = { - workspaceId: selected.workspaceId, - client, - bindings: new Map(), - }; - const staged = await stageBindings(owner, candidates).catch(async (error) => { - await client.close(); - throw error; - }); - let finished = false; - return { - async commit() { - if (finished) return; - finished = true; - const previous = scope.owner; - await previous?.client.close(); - scope.owner = owner; - await staged.commit(); - }, - async rollback() { - if (finished) return; - finished = true; - await staged.rollback(); - await client.close(); - }, - }; - } - - async function stageBindings( - owner: PhysicalOwner, - observations: readonly LogicalObservation[], - ): Promise { - let committed = false; - const staged = new Map(); - try { - for (const observation of observations) { - if (owner.bindings.has(observation)) continue; - staged.set( - observation, - await owner.client.files.subscribe( - { paths: ["."], recursive: true, ignoredPaths: ["objects"] }, - (event) => { - if ( - committed && - event.type !== "error" && - !options.isUnavailable(observation.workspaceId) - ) { - observation.listener(); - } - }, - ), - ); - } - } catch (error) { - await Promise.allSettled([...staged.values()].map((binding) => binding.unsubscribe())); - throw error; - } - let finished = false; - return { - async commit() { - if (finished) return; - finished = true; - for (const [observation, binding] of staged) owner.bindings.set(observation, binding); - committed = true; - }, - async rollback() { - if (finished) return; - finished = true; - await Promise.allSettled([...staged.values()].map((binding) => binding.unsubscribe())); - }, - }; - } - - function readyObservations( - scope: SharedScope, - allowedWorkspaceId?: string, - ): LogicalObservation[] { - return [...scope.observations].filter( - (observation) => - observation.workspaceId === allowedWorkspaceId || - !options.isUnavailable(observation.workspaceId), - ); - } - - function removeLogicalObservation(observation: LogicalObservation): void { - observation.scope.observations.delete(observation); - } - - async function closeEmptyScope(scope: SharedScope): Promise { - if (scope.observations.size > 0) return; - await closeOwner(scope); - if (scopes.get(scope.key) === scope) scopes.delete(scope.key); - } - - async function closeOwner(scope: SharedScope): Promise { - const owner = scope.owner; - scope.owner = null; - await owner?.client.close(); - } - - async function discoverCommonGitDirectory( - driver: WorkspaceRuntimeDriver, - workspaceId: string, - ): Promise { - const process = await options.runGit(driver, workspaceId, [ - "git", - "rev-parse", - "--path-format=absolute", - "--git-common-dir", - ]); - if (process.kind !== "pipes") throw new Error("Git common directory discovery requires pipes"); - process.stdin.end(); - const [stdout, stderr, exit] = await Promise.all([ - collect(process.stdout, process), - collect(process.stderr, process), - process.exited, - ]); - if (exit.code !== 0 || exit.signal !== null) return null; - const root = stdout.trim(); - if (!root && stderr.trim()) return null; - return root || null; - } - - async function withScope(scope: SharedScope, operation: () => Promise): Promise { - const locked = await lockScopes([scope]); - try { - return await operation(); - } finally { - locked.release(); - } - } - - async function lockScopes( - requested: readonly SharedScope[], - ): Promise<{ scopes: SharedScope[]; release(): void }> { - const ordered = [...new Set(requested)].sort((left, right) => - left.key.localeCompare(right.key), - ); - const releases: Array<() => void> = []; - for (const scope of ordered) { - const previous = scope.tail; - let release!: () => void; - const gate = new Promise((resolve) => { - release = resolve; - }); - scope.tail = previous.then(() => gate); - await previous; - releases.push(release); - } - let released = false; - return { - scopes: ordered, - release() { - if (released) return; - released = true; - for (const release of releases.toReversed()) release(); - }, - }; - } -} - -function combineTransactions( - transactions: readonly ObservationRebindTransaction[], -): ObservationRebindTransaction { - let finished = false; - return { - async commit() { - if (finished) return; - finished = true; - for (const transaction of transactions) await transaction.commit(); - }, - async rollback() { - if (finished) return; - finished = true; - await Promise.allSettled(transactions.map((transaction) => transaction.rollback())); - }, - }; -} - -function noOpTransaction(): ObservationRebindTransaction { - return { commit: async () => {}, rollback: async () => {} }; -} - -function finishWithRelease( - transaction: ObservationRebindTransaction, - release: () => void, -): ObservationRebindTransaction { - let finished = false; - return { - async commit() { - if (finished) return; - finished = true; - try { - await transaction.commit(); - } finally { - release(); - } - }, - async rollback() { - if (finished) return; - finished = true; - try { - await transaction.rollback(); - } finally { - release(); - } - }, - }; -} - -function assertRuntimeAbsolutePath(root: string): void { - if (!nodePath.posix.isAbsolute(root) && !nodePath.win32.isAbsolute(root)) { - throw new Error("Git common directory must be runtime-absolute"); - } -} - -async function collect( - stream: NodeJS.ReadableStream, - process: WorkspaceDriverProcess, -): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of stream) { - const bytes = Buffer.from(chunk); - size += bytes.length; - if (size > MAX_DISCOVERY_OUTPUT_BYTES) { - process.kill("SIGKILL"); - throw new Error("Git common directory discovery exceeded 64 KiB"); - } - chunks.push(bytes); - } - return Buffer.concat(chunks).toString("utf8"); -} 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 new file mode 100644 index 0000000000..843bf458fd --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts @@ -0,0 +1,111 @@ +import type { BoundWorkspaceRuntime } from "../../index.js"; +import type { WorkspaceRuntimeDriver } from "../../drivers/index.js"; +import type { WorkspaceFilesSubscription } from "../../../workspace-helper/index.js"; +import { registerGitCommonObservationCapability } from "./capability.js"; + +interface GitObservation { + workspaceId: string; + runtime: BoundWorkspaceRuntime; + driver: WorkspaceRuntimeDriver; + listener: () => void; + physical: { unsubscribe(): Promise } | null; +} + +export interface ObservationRebindTransaction { + commit(): Promise; + rollback(): Promise; +} + +export interface GitCommonObservationCoordinator { + bind(runtime: BoundWorkspaceRuntime, workspaceId: string, driver: WorkspaceRuntimeDriver): void; + pause(workspaceId: string): Promise; + stageResume(workspaceId: string): Promise; + destroy(workspaceId: string): Promise; +} + +/** Git invalidation is workspace-bound. Runtime-private observers never disclose placement. */ +export function createGitCommonObservationCoordinator(): GitCommonObservationCoordinator { + const observations = new Set(); + + return { + bind(runtime, workspaceId, driver) { + registerGitCommonObservationCapability(runtime, { + observe: async (listener) => { + const observation: GitObservation = { + workspaceId, + runtime, + driver, + listener, + physical: null, + }; + observation.physical = await start(observation); + observations.add(observation); + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + observations.delete(observation); + const physical = observation.physical; + observation.physical = null; + await physical?.unsubscribe(); + }, + }; + }, + }); + }, + async pause(workspaceId) { + for (const observation of owned(workspaceId)) { + const physical = observation.physical; + observation.physical = null; + await physical?.unsubscribe(); + } + }, + async stageResume(workspaceId) { + const staged = new Map }>(); + try { + for (const observation of owned(workspaceId)) { + if (!observation.physical) staged.set(observation, await start(observation)); + } + } catch (error) { + await Promise.allSettled([...staged.values()].map((item) => item.unsubscribe())); + throw error; + } + let finished = false; + return { + async commit() { + if (finished) return; + finished = true; + for (const [observation, physical] of staged) observation.physical = physical; + }, + async rollback() { + if (finished) return; + finished = true; + await Promise.allSettled([...staged.values()].map((item) => item.unsubscribe())); + }, + }; + }, + async destroy(workspaceId) { + const selected = owned(workspaceId); + for (const observation of selected) observations.delete(observation); + await Promise.allSettled(selected.map((observation) => observation.physical?.unsubscribe())); + }, + }; + + function owned(workspaceId: string): GitObservation[] { + return [...observations].filter((observation) => observation.workspaceId === workspaceId); + } + + async function start(observation: GitObservation) { + if (observation.driver.observeGit) { + return observation.driver.observeGit(observation.workspaceId, observation.listener); + } + const subscription: WorkspaceFilesSubscription = await observation.runtime.files.subscribe( + { paths: ["."], recursive: true }, + (event) => { + if (event.type !== "error") observation.listener(); + }, + ); + return subscription; + } +} diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 0f18da98ee..f39f6a5c60 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -3,6 +3,7 @@ import type { Readable, Writable } from "node:stream"; import type { WorkspaceFiles } from "../workspace-helper/index.js"; import { createCommandRuntimeAdapter } from "./command/index.js"; +import { createHostGitObservationOwner } from "./internal/host-git-observation.js"; import { createLocalRuntime } from "./internal/local-runtime.js"; import { createService } from "./internal/service.js"; import { createWorktreeRuntime } from "./internal/worktree-runtime.js"; @@ -113,7 +114,6 @@ export interface BoundWorkspaceRuntime { run(input: Omit): Promise; resolveCommand(command: string): Promise; readonly files: WorkspaceFiles; - readonly homeFiles: WorkspaceFiles; } export interface WorkspaceRuntimeService { @@ -178,6 +178,7 @@ export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore { export function createWorkspaceRuntimeService( options: WorkspaceRuntimeOptions, ): WorkspaceRuntimeService { + const hostGitObservations = createHostGitObservationOwner(); const externalDrivers = Object.entries(options.externalRuntimes ?? {}).map( ([runtimeId, config]) => { if (runtimeId === "local" || runtimeId === "worktree") { @@ -188,10 +189,11 @@ export function createWorkspaceRuntimeService( ); return createService( [ - createLocalRuntime(options.paseoHome), + createLocalRuntime(options.paseoHome, hostGitObservations), createWorktreeRuntime({ paseoHome: options.paseoHome, worktreesRoot: options.worktreesRoot, + hostGitObservations, }), ...externalDrivers, ], diff --git a/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts b/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts new file mode 100644 index 0000000000..2cd333f1fe --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/host-git-observation.posix.test.ts @@ -0,0 +1,135 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { expect, test } from "vitest"; + +import { + createFileObserver, + type FileObserver, + type FileObserverCallback, + type FileObserverOptions, +} from "../../file-observer/index.js"; +import { createHostGitObservationOwner } from "./host-git-observation.js"; + +test("sibling host worktrees share one physical common-Git observation", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-observation-")); + try { + const source = await repository(path.join(root, "source")); + const sibling = path.join(root, "sibling"); + git(source, ["branch", "sibling"]); + git(source, ["worktree", "add", sibling, "sibling"]); + const unrelated = await repository(path.join(root, "unrelated")); + const owner = createHostGitObservationOwner(); + + const [sourceSubscription, siblingSubscription] = await Promise.all([ + owner.observe(source, () => undefined), + owner.observe(sibling, () => undefined), + ]); + expect(owner.getDiagnostics().activeObservationCount).toBe(1); + + const unrelatedSubscription = await owner.observe(unrelated, () => undefined); + expect(owner.getDiagnostics().activeObservationCount).toBe(2); + + await sourceSubscription.unsubscribe(); + expect(owner.getDiagnostics().activeObservationCount).toBe(2); + await siblingSubscription.unsubscribe(); + expect(owner.getDiagnostics().activeObservationCount).toBe(1); + await unrelatedSubscription.unsubscribe(); + expect(owner.getDiagnostics().activeObservationCount).toBe(0); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("a failed physical common watcher rebinds once without changing logical ownership", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-rebind-")); + try { + const source = await repository(path.join(root, "source")); + const sibling = path.join(root, "sibling"); + git(source, ["branch", "sibling"]); + git(source, ["worktree", "add", sibling, "sibling"]); + const observer = new InstrumentedFileObserver(); + const owner = createHostGitObservationOwner(observer); + let notifications = 0; + const subscriptions = await Promise.all([ + owner.observe(source, () => { + notifications += 1; + }), + owner.observe(sibling, () => { + notifications += 1; + }), + ]); + expect(observer.subscribeCount).toBe(1); + expect(observer.getDiagnostics().activeObservationCount).toBe(1); + + observer.failActive(new Error("physical watcher failed")); + const barrier = await owner.observe(sibling, () => undefined); + expect(notifications).toBe(2); + expect(observer.subscribeCount).toBe(2); + expect(observer.getDiagnostics().activeObservationCount).toBe(1); + + await Promise.all([ + ...subscriptions.map((subscription) => subscription.unsubscribe()), + barrier.unsubscribe(), + ]); + expect(observer.getDiagnostics().activeObservationCount).toBe(0); + await observer.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +class InstrumentedFileObserver implements FileObserver { + readonly observer = createFileObserver(); + readonly callbacks = new Set(); + subscribeCount = 0; + + async subscribe( + directory: string, + callback: FileObserverCallback, + options?: FileObserverOptions, + ) { + this.subscribeCount += 1; + this.callbacks.add(callback); + const subscription = await this.observer.subscribe(directory, callback, options); + let active = true; + return { + updateIgnore: (paths: string[]) => subscription.updateIgnore(paths), + unsubscribe: async () => { + if (!active) return; + active = false; + this.callbacks.delete(callback); + await subscription.unsubscribe(); + }, + }; + } + + failActive(error: Error): void { + for (const callback of this.callbacks) callback(error, []); + } + + getDiagnostics() { + return this.observer.getDiagnostics(); + } + + close(): Promise { + return this.observer.close(); + } +} + +async function repository(directory: string): Promise { + await mkdir(directory); + git(directory, ["init", "-b", "main"]); + git(directory, ["config", "user.email", "test@getpaseo.local"]); + git(directory, ["config", "user.name", "Paseo Test"]); + await writeFile(path.join(directory, "tracked.txt"), "tracked\n"); + git(directory, ["add", "."]); + git(directory, ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"]); + return directory; +} + +function git(cwd: string, argv: string[]): void { + execFileSync("git", argv, { cwd, stdio: "pipe" }); +} diff --git a/packages/server/src/server/workspace-runtime/internal/host-git-observation.ts b/packages/server/src/server/workspace-runtime/internal/host-git-observation.ts new file mode 100644 index 0000000000..85e0fea89d --- /dev/null +++ b/packages/server/src/server/workspace-runtime/internal/host-git-observation.ts @@ -0,0 +1,110 @@ +import { + createFileObserver, + type FileObserver, + type FileObserverDiagnostics, + type FileObserverSubscription, +} from "../../file-observer/index.js"; +import { realpath } from "node:fs/promises"; +import { getGitCommonDir } from "../../../utils/worktree.js"; + +interface CommonObservation { + listeners: Set<() => void>; + subscription: FileObserverSubscription | null; +} + +export interface HostGitObservationOwner { + observe(root: string, listener: () => void): Promise<{ unsubscribe(): Promise }>; + getDiagnostics(): FileObserverDiagnostics; +} + +/** One private physical common-Git watcher per host common directory. */ +export function createHostGitObservationOwner( + observer: FileObserver = createFileObserver(), +): HostGitObservationOwner { + const observations = new Map(); + const tails = new Map>(); + + return { + async observe(root, listener) { + const commonDirectory = await realpath(await getGitCommonDir(root)); + await sequence(commonDirectory, async () => { + let observation = observations.get(commonDirectory); + if (!observation) { + const listeners = new Set<() => void>(); + observation = { listeners, subscription: null }; + observations.set(commonDirectory, observation); + } + observation.subscription ??= await subscribe(commonDirectory, observation); + observation.listeners.add(listener); + }); + let active = true; + return { + async unsubscribe() { + if (!active) return; + active = false; + await sequence(commonDirectory, async () => { + const observation = observations.get(commonDirectory); + if (!observation) return; + observation.listeners.delete(listener); + if (observation.listeners.size > 0) return; + observations.delete(commonDirectory); + await observation.subscription?.unsubscribe(); + observation.subscription = null; + }); + }, + }; + }, + getDiagnostics() { + return observer.getDiagnostics(); + }, + }; + + async function sequence(key: string, operation: () => Promise): Promise { + const previous = tails.get(key) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + tails.set(key, current); + try { + await current; + } finally { + if (tails.get(key) === current) tails.delete(key); + } + } + + async function subscribe( + commonDirectory: string, + observation: CommonObservation, + ): Promise { + let subscription!: FileObserverSubscription; + subscription = await observer.subscribe(commonDirectory, (error, events) => { + if (error || events.length > 0) { + for (const notify of observation.listeners) notify(); + } + if (error) rebindAfterFailure(commonDirectory, observation, subscription); + }); + return subscription; + } + + function rebindAfterFailure( + commonDirectory: string, + observation: CommonObservation, + failedSubscription: FileObserverSubscription, + ): void { + void sequence(commonDirectory, async () => { + if ( + observations.get(commonDirectory) !== observation || + observation.subscription !== failedSubscription + ) { + return; + } + observation.subscription = null; + await failedSubscription.unsubscribe(); + if (observation.listeners.size === 0) { + observations.delete(commonDirectory); + return; + } + observation.subscription = await subscribe(commonDirectory, observation); + }).catch(() => { + for (const notify of observation.listeners) notify(); + }); + } +} diff --git a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts index d92f69fa22..ccbb688041 100644 --- a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts @@ -10,14 +10,21 @@ import type { } from "../drivers/index.js"; import { resolveRuntimeCwd, spawnHostProcess, spawnHostPty } from "./host-process.js"; import { hostWorkspaceHelperCommand } from "./host-helper.js"; +import type { HostGitObservationOwner } from "./host-git-observation.js"; import { createRuntimeStateStore } from "./runtime-state.js"; -interface LocalRuntimeState extends WorkspaceDriverState { +interface LocalRuntimeState { + workspaceId: string; + root: string; + lifecycle: "ready" | "paused"; compatibilityCwd?: string; } -export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { - const states = createRuntimeStateStore(paseoHome, "local"); +export function createLocalRuntime( + paseoHome: string, + hostGitObservations: HostGitObservationOwner, +): WorkspaceRuntimeDriver { + const states = createRuntimeStateStore(paseoHome, "local", isLocalRuntimeState); async function inspect(workspaceId: string): Promise { const state = (await states.read(workspaceId)) as LocalRuntimeState | null; @@ -26,7 +33,7 @@ export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; return { status: state.lifecycle, - state, + state: publicState(state), placement: hostPlacement(state.compatibilityCwd ?? state.root), }; } catch (error) { @@ -35,12 +42,14 @@ export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { } } - async function requireReady(workspaceId: string): Promise { - const inspection = await inspect(workspaceId); - if (inspection.status !== "ready") { - throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`); + async function requireReady(workspaceId: string): Promise { + const state = await states.read(workspaceId); + if (!state || state.lifecycle !== "ready") { + throw new Error( + `Workspace runtime local is ${state?.lifecycle ?? "missing"}: ${workspaceId}`, + ); } - return inspection.state; + return state; } return { @@ -59,36 +68,35 @@ export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { const state: LocalRuntimeState = { workspaceId: input.workspaceId, root, - revision: "local:1", - executionDomainId: "host", lifecycle: "ready", compatibilityCwd, }; await states.write(state); - return { state, placement: hostPlacement(compatibilityCwd) }; + return { state: publicState(state), placement: hostPlacement(compatibilityCwd) }; }, inspect, async spawn(input: WorkspaceDriverSpawnInput) { const root = (await requireReady(input.workspaceId)).root; return input.stdio.kind === "pty" ? spawnHostPty(root, input) : spawnHostProcess(root, input); }, + async observeGit(workspaceId, listener) { + return hostGitObservations.observe((await requireReady(workspaceId)).root, listener); + }, async pause(workspaceId) { - const inspection = await inspect(workspaceId); - if (inspection.status === "paused") return; - if (inspection.status !== "ready") { - throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`); - } - await states.write({ ...inspection.state, lifecycle: "paused" }); + const state = await states.read(workspaceId); + if (state?.lifecycle === "paused") return; + if (!state) throw new Error(`Workspace runtime local is missing: ${workspaceId}`); + await states.write({ ...state, lifecycle: "paused" }); }, async resume(workspaceId) { - const inspection = await inspect(workspaceId); - if (inspection.status === "missing" || inspection.status === "error") { - throw new Error(`Workspace runtime local is ${inspection.status}: ${workspaceId}`); - } - const state = { ...inspection.state, lifecycle: "ready" as const }; + const current = await states.read(workspaceId); + if (!current) throw new Error(`Workspace runtime local is missing: ${workspaceId}`); + const state = { ...current, lifecycle: "ready" as const }; await states.write(state); - const localState = state as LocalRuntimeState; - return { state, placement: hostPlacement(localState.compatibilityCwd ?? state.root) }; + return { + state: publicState(state), + placement: hostPlacement(state.compatibilityCwd ?? state.root), + }; }, async destroy(workspaceId) { await states.remove(workspaceId); @@ -96,6 +104,21 @@ export function createLocalRuntime(paseoHome: string): WorkspaceRuntimeDriver { }; } +function publicState(state: LocalRuntimeState): WorkspaceDriverState { + return { workspaceId: state.workspaceId, lifecycle: state.lifecycle }; +} + +function isLocalRuntimeState(value: unknown, workspaceId: string): value is LocalRuntimeState { + if (!value || typeof value !== "object") return false; + const state = value as Partial; + return ( + state.workspaceId === workspaceId && + typeof state.root === "string" && + (state.lifecycle === "ready" || state.lifecycle === "paused") && + (state.compatibilityCwd === undefined || typeof state.compatibilityCwd === "string") + ); +} + function hostPlacement(root: string) { return { cwd: root, hostVisiblePath: root }; } diff --git a/packages/server/src/server/workspace-runtime/internal/runtime-state.ts b/packages/server/src/server/workspace-runtime/internal/runtime-state.ts index 962f9b6437..dc6856fabb 100644 --- a/packages/server/src/server/workspace-runtime/internal/runtime-state.ts +++ b/packages/server/src/server/workspace-runtime/internal/runtime-state.ts @@ -2,24 +2,26 @@ import { createHash } from "node:crypto"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import type { WorkspaceDriverState } from "../drivers/index.js"; - -export function createRuntimeStateStore(paseoHome: string, runtimeId: string) { +export function createRuntimeStateStore( + paseoHome: string, + runtimeId: string, + validate: (value: unknown, workspaceId: string) => value is T, +) { const directory = path.join(paseoHome, "workspace-runtimes", runtimeId); const fileFor = (workspaceId: string) => path.join(directory, `${createHash("sha256").update(workspaceId).digest("hex")}.json`); return { - async read(workspaceId: string): Promise { + async read(workspaceId: string): Promise { try { const parsed = JSON.parse(await readFile(fileFor(workspaceId), "utf8")) as unknown; - if (!isWorkspaceDriverState(parsed, workspaceId)) return null; + if (!validate(parsed, workspaceId)) return null; return parsed; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } }, - async write(state: WorkspaceDriverState): Promise { + async write(state: T): Promise { await mkdir(directory, { recursive: true }); const target = fileFor(state.workspaceId); const temporary = `${target}.${process.pid}.tmp`; @@ -31,18 +33,3 @@ export function createRuntimeStateStore(paseoHome: string, runtimeId: string) { }, }; } - -function isWorkspaceDriverState( - value: unknown, - workspaceId: string, -): value is WorkspaceDriverState { - if (!value || typeof value !== "object") return false; - const state = value as Partial; - return ( - state.workspaceId === workspaceId && - typeof state.root === "string" && - typeof state.revision === "string" && - typeof state.executionDomainId === "string" && - (state.lifecycle === "ready" || state.lifecycle === "paused") - ); -} diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index 424b80d1b4..aed6bcbb10 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -21,12 +21,12 @@ import type { import { bindWorkspaceHelper, type WorkspaceFilesOwner, -} from "../../workspace-helper/integration/index.js"; +} from "../../workspace-helper/internal/integration/index.js"; import { PaseoConfigSchema } from "@getpaseo/protocol/paseo-config-schema"; import { createGitCommonObservationCoordinator, type ObservationRebindTransaction, -} from "../git-observation/integration.js"; +} from "../git-observation/internal/integration.js"; const gracefulStopMilliseconds = 1_000; const forcedStopMilliseconds = 1_000; @@ -38,14 +38,7 @@ export function createService( const driversById = new Map(drivers.map((driver) => [driver.id, driver])); const processesByWorkspaceId = new Map>(); const workspaceTails = new Map>(); - const fileClients = new Map< - string, - { runtimeId: string; revision: string; client: WorkspaceFilesOwner } - >(); - const homeFileClients = new Map< - string, - { runtimeId: string; revision: string; client: WorkspaceFilesOwner } - >(); + const fileClients = new Map(); const boundFiles = new Map(); const fileSubscriptions = new Map< string, @@ -56,25 +49,8 @@ export function createService( }> >(); const unavailableFiles = new Map(); - const boundRuntimes = new Map< - string, - { runtimeId: string; revision: string; runtime: BoundWorkspaceRuntime } - >(); - const gitCommonObservations = createGitCommonObservationCoordinator({ - launchHelper: (driver, workspaceId, argv) => launchHelper(driver, workspaceId, argv), - runGit: async (driver, workspaceId, argv) => { - const runtimeProcess = await driver.spawn({ - workspaceId, - argv, - env: lifecycleEnvironment(), - purpose: { kind: "git" }, - stdio: { kind: "pipes" }, - }); - trackProcess(workspaceId, runtimeProcess); - return runtimeProcess; - }, - isUnavailable: (workspaceId) => unavailableFiles.has(workspaceId), - }); + const boundRuntimes = new Map(); + const gitCommonObservations = createGitCommonObservationCoordinator(); function requireRegistered(runtimeId: string): WorkspaceRuntimeDriver { const driver = driversById.get(runtimeId); @@ -93,7 +69,6 @@ export function createService( input: WorkspaceProcessInput, ): Promise { assertWorkspaceAvailable(input.workspaceId); - validateRelativeCwd(input.cwd); const inspection = await driver.inspect(input.workspaceId); if (inspection.status !== "ready") { throw new Error(`Workspace runtime is ${inspection.status}: ${input.workspaceId}`); @@ -111,7 +86,6 @@ export function createService( input: WorkspaceTerminalInput, ): Promise { assertWorkspaceAvailable(input.workspaceId); - validateRelativeCwd(input.cwd); const inspection = await driver.inspect(input.workspaceId); if (inspection.status !== "ready") { throw new Error(`Workspace runtime is ${inspection.status}: ${input.workspaceId}`); @@ -198,7 +172,6 @@ export function createService( ownsNewResource = before.status === "missing"; if (ready.state.lifecycle === "ready") { const helper = bindWorkspaceHelper({ - root: ready.state.root, command: driver.workspaceHelperCommand, launch: (argv) => launchHelper(driver, input.workspaceId, argv), }); @@ -269,7 +242,7 @@ export function createService( throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); } const cached = boundRuntimes.get(workspaceId); - if (cached?.runtimeId === driver.id && cached.revision === inspection.state.revision) { + if (cached?.runtimeId === driver.id) { return cached.runtime; } const runtime: BoundWorkspaceRuntime = { @@ -277,12 +250,10 @@ export function createService( resolveCommand: async (command) => (await requireFilesOwner(workspaceId)).resolveCommand(command), files: bindFiles(workspaceId), - homeFiles: bindHomeFiles(workspaceId), }; gitCommonObservations.bind(runtime, workspaceId, driver); boundRuntimes.set(workspaceId, { runtimeId: driver.id, - revision: inspection.state.revision, runtime, }); return runtime; @@ -539,26 +510,6 @@ export function createService( }; } - function bindHomeFiles(workspaceId: string): WorkspaceFiles { - return { - async stat(path) { - return (await requireHomeFilesOwner(workspaceId)).files.stat(path); - }, - async list(path) { - return (await requireHomeFilesOwner(workspaceId)).files.list(path); - }, - async read(path) { - return (await requireHomeFilesOwner(workspaceId)).files.read(path); - }, - async write(input) { - return (await requireHomeFilesOwner(workspaceId)).files.write(input); - }, - async subscribe(input, listener) { - return (await requireHomeFilesOwner(workspaceId)).files.subscribe(input, listener); - }, - }; - } - async function requireFiles(workspaceId: string): Promise { return (await requireFilesOwner(workspaceId)).files; } @@ -576,12 +527,11 @@ export function createService( throw new Error(`Workspace runtime is ${inspection.status}: ${workspaceId}`); } const cached = fileClients.get(workspaceId); - if (cached && cached.runtimeId === driver.id && cached.revision === inspection.state.revision) { + if (cached && cached.runtimeId === driver.id) { return cached.client; } if (cached) await cached.client.close(); const client = bindWorkspaceHelper({ - root: inspection.state.root, command: driver.workspaceHelperCommand, launch: async (argv) => { return launchHelper(driver, workspaceId, argv); @@ -589,33 +539,6 @@ export function createService( }); fileClients.set(workspaceId, { runtimeId: driver.id, - revision: inspection.state.revision, - client, - }); - return client; - } - - async function requireHomeFilesOwner(workspaceId: string): Promise { - const unavailable = unavailableFiles.get(workspaceId); - if (unavailable) throw new Error(`Workspace runtime is ${unavailable}: ${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 cached = homeFileClients.get(workspaceId); - if (cached && cached.runtimeId === driver.id && cached.revision === inspection.state.revision) { - return cached.client; - } - if (cached) await cached.client.close(); - const client = bindWorkspaceHelper({ - root: "@home", - command: driver.workspaceHelperCommand, - launch: async (argv) => launchHelper(driver, workspaceId, argv), - }); - homeFileClients.set(workspaceId, { - runtimeId: driver.id, - revision: inspection.state.revision, client, }); return client; @@ -623,16 +546,13 @@ export function createService( async function closeFiles(workspaceId: string, forgetSubscriptions = false): Promise { const cached = fileClients.get(workspaceId); - const cachedHome = homeFileClients.get(workspaceId); fileClients.delete(workspaceId); - homeFileClients.delete(workspaceId); const subscriptions = fileSubscriptions.get(workspaceId); if (subscriptions) { for (const subscription of subscriptions) subscription.bound = null; if (forgetSubscriptions) fileSubscriptions.delete(workspaceId); } if (cached) await cached.client.close(); - if (cachedHome) await cachedHome.client.close(); } async function stageSubscriptionRebind( @@ -802,14 +722,6 @@ function lifecycleEnvironment(): Readonly> { return { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin" }; } -function validateRelativeCwd(cwd: string | undefined): void { - if (cwd === undefined) return; - const parts = cwd.split(/[\\/]/u); - if (cwd.startsWith("/") || cwd.includes("\\") || parts.includes("..")) { - throw new Error(`Workspace cwd must stay within the runtime root: ${cwd}`); - } -} - function toDriverCreateInput(input: CreateWorkspaceInput): WorkspaceDriverCreateInput { return { workspaceId: input.workspaceId, 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 b516519458..f8d1c1acf2 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -4,7 +4,6 @@ import path from "node:path"; import { createWorktree, deletePaseoWorktree, - getGitCommonDir, seedPaseoConfigFile, type WorktreeSource, } from "../../../utils/worktree.js"; @@ -17,38 +16,50 @@ import type { } from "../drivers/index.js"; import { resolveRuntimeCwd, spawnHostProcess, spawnHostPty } from "./host-process.js"; import { hostWorkspaceHelperCommand } 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"; -interface WorktreeRuntimeState extends WorkspaceDriverState { +interface WorktreeRuntimeState { + workspaceId: string; + root: string; sourceRoot: string; worktreeRoot: string; + lifecycle: "ready" | "paused"; + lifecycleEnvironment: Readonly>; } export function createWorktreeRuntime(options: { paseoHome: string; worktreesRoot?: string; + hostGitObservations: HostGitObservationOwner; }): WorkspaceRuntimeDriver { - const states = createRuntimeStateStore(options.paseoHome, "worktree"); + const states = createRuntimeStateStore(options.paseoHome, "worktree", isWorktreeRuntimeState); async function inspect(workspaceId: string): Promise { const state = await states.read(workspaceId); if (!state) return { status: "missing" }; try { if (!(await stat(state.root)).isDirectory()) return { status: "missing" }; - return { status: state.lifecycle, state, placement: hostPlacement(state.root) }; + return { + status: state.lifecycle, + state: publicState(state), + placement: hostPlacement(state.root), + }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { status: "missing" }; return { status: "error", message: String(error) }; } } - async function requireReady(workspaceId: string): Promise { - const inspection = await inspect(workspaceId); - if (inspection.status !== "ready") { - throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`); + async function requireReady(workspaceId: string): Promise { + const state = await states.read(workspaceId); + if (!state || state.lifecycle !== "ready") { + throw new Error( + `Workspace runtime worktree is ${state?.lifecycle ?? "missing"}: ${workspaceId}`, + ); } - return inspection.state; + return state; } return { @@ -108,18 +119,16 @@ export function createWorktreeRuntime(options: { root, worktreeRoot: worktree.worktreePath, sourceRoot, - revision: `worktree:${Date.now()}`, - executionDomainId: "host", lifecycle: "ready", lifecycleEnvironment: { - PASEO_SOURCE_CHECKOUT_PATH: sourceRoot, - PASEO_ROOT_PATH: sourceRoot, - PASEO_WORKTREE_PATH: worktree.worktreePath, + PASEO_SOURCE_CHECKOUT_PATH: ".", + PASEO_ROOT_PATH: ".", + PASEO_WORKTREE_PATH: ".", PASEO_BRANCH_NAME: worktree.branchName, }, }; await states.write(state); - return { state, placement: hostPlacement(root) }; + return { state: publicState(state), placement: hostPlacement(root) }; } catch (error) { await deletePaseoWorktree({ cwd: sourceRoot, @@ -136,34 +145,28 @@ export function createWorktreeRuntime(options: { const root = (await requireReady(input.workspaceId)).root; return input.stdio.kind === "pty" ? spawnHostPty(root, input) : spawnHostProcess(root, input); }, + async observeGit(workspaceId, listener) { + return options.hostGitObservations.observe((await requireReady(workspaceId)).root, listener); + }, async pause(workspaceId) { - const inspection = await inspect(workspaceId); - if (inspection.status === "paused") return; - if (inspection.status !== "ready") { - throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`); - } - await states.write({ ...inspection.state, lifecycle: "paused" }); + const state = await states.read(workspaceId); + if (state?.lifecycle === "paused") return; + if (!state) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`); + await states.write({ ...state, lifecycle: "paused" }); }, async resume(workspaceId) { - const inspection = await inspect(workspaceId); - if (inspection.status === "missing" || inspection.status === "error") { - throw new Error(`Workspace runtime worktree is ${inspection.status}: ${workspaceId}`); - } - const state = { ...inspection.state, lifecycle: "ready" as const }; + const current = await states.read(workspaceId); + if (!current) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`); + const state = { ...current, lifecycle: "ready" as const }; await states.write(state); - return { state, placement: hostPlacement(state.root) }; + return { state: publicState(state), placement: hostPlacement(state.root) }; }, async destroy(workspaceId) { - const state = (await states.read(workspaceId)) as WorktreeRuntimeState | null; + const state = await states.read(workspaceId); if (!state) return; - const sourceRoot = - typeof state.sourceRoot === "string" - ? state.sourceRoot - : path.dirname(await getGitCommonDir(state.root)); - const worktreeRoot = typeof state.worktreeRoot === "string" ? state.worktreeRoot : state.root; await deletePaseoWorktree({ - cwd: sourceRoot, - worktreePath: worktreeRoot, + cwd: state.sourceRoot, + worktreePath: state.worktreeRoot, teardownCwds: [], paseoHome: options.paseoHome, worktreesBaseRoot: options.worktreesRoot, @@ -173,6 +176,31 @@ export function createWorktreeRuntime(options: { }; } +function publicState(state: WorktreeRuntimeState): WorkspaceDriverState { + return { + workspaceId: state.workspaceId, + lifecycle: state.lifecycle, + lifecycleEnvironment: state.lifecycleEnvironment, + }; +} + +function isWorktreeRuntimeState( + value: unknown, + workspaceId: string, +): value is WorktreeRuntimeState { + if (!value || typeof value !== "object") return false; + const state = value as Partial; + return ( + state.workspaceId === workspaceId && + typeof state.root === "string" && + typeof state.sourceRoot === "string" && + typeof state.worktreeRoot === "string" && + (state.lifecycle === "ready" || state.lifecycle === "paused") && + !!state.lifecycleEnvironment && + typeof state.lifecycleEnvironment === "object" + ); +} + function hostPlacement(root: string) { return { cwd: root, hostVisiblePath: root }; } 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 1cb52d0e1f..93c9786e3b 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 @@ -1,6 +1,6 @@ import { execFileSync } from "node:child_process"; import { existsSync, watch } from "node:fs"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,7 +10,7 @@ import { afterEach, expect, test, vi } from "vitest"; import { createWorkspaceRuntimeService } from "./index.js"; const fixtureExecutable = fileURLToPath( - new URL("../test-utils/fixtures/workspace-runtime-command-fixture.mjs", import.meta.url), + new URL("../../../../fixture-workspace-runtime/src/index.mjs", import.meta.url), ); const helperExecutable = fileURLToPath( new URL("../workspace-helper/executable.mjs", import.meta.url), @@ -69,6 +69,119 @@ test("a trusted registered command is selected and receives secret launch data o await fixture.service.destroy(fixture.workspaceId); }); +test("equal display cwd values never share external runtime execution, files, Git, or caches", async () => { + const root = await mkdtemp(path.join(tmpdir(), "paseo-command-runtime-equal-display-")); + cleanupRoots.push(root); + const sources = [path.join(root, "source-a"), path.join(root, "source-b")]; + const stateDirectories = [path.join(root, "state-a"), path.join(root, "state-b")]; + await Promise.all([...sources, ...stateDirectories].map((directory) => mkdir(directory))); + for (const [index, source] of sources.entries()) { + execFileSync("git", ["init", "-b", "main"], { cwd: source }); + execFileSync("git", ["config", "user.email", "paseo@example.com"], { cwd: source }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: source }); + await writeFile(path.join(source, "tracked.txt"), "base\n"); + execFileSync("git", ["add", "tracked.txt"], { cwd: source }); + execFileSync("git", ["commit", "-m", "base"], { cwd: source }); + await writeFile(path.join(source, `only-${index === 0 ? "a" : "b"}.txt`), "before"); + } + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(root, "paseo-home"), + resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null, + persistRuntimeId: async (id, runtimeId) => void runtimeIds.set(id, runtimeId), + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (id) => void runtimeIds.delete(id), + externalRuntimes: Object.fromEntries( + ["a", "b"].map((suffix, index) => [ + `fixture-${suffix}`, + { + type: "command" as const, + command: [processExecPath(), fixtureExecutable] as const, + helperCommand: [processExecPath(), helperExecutable] as const, + options: { + stateDirectory: stateDirectories[index], + displayCwd: "/workspace", + exposeHostVisiblePath: false, + }, + }, + ]), + ), + }); + + for (const [index, suffix] of ["a", "b"].entries()) { + await service.create({ + workspaceId: `equal-${suffix}`, + runtimeId: `fixture-${suffix}`, + project: { + id: `project-${suffix}`, + source: { kind: "host-directory", path: sources[index] }, + }, + placement: { kind: "existing" }, + }); + } + await expect(service.inspect("equal-a")).resolves.toEqual({ status: "ready", cwd: "/workspace" }); + await expect(service.inspect("equal-b")).resolves.toEqual({ status: "ready", cwd: "/workspace" }); + await expect(service.requireHostVisiblePath("equal-a")).rejects.toThrow("no host-visible path"); + expect(await service.bind("equal-a")).not.toBe(await service.bind("equal-b")); + + await expect( + service.files("equal-a").write({ path: "only-a.txt", contents: Buffer.from("a") }), + ).resolves.toMatchObject({ status: "written" }); + await expect( + service.files("equal-b").write({ path: "only-b.txt", contents: Buffer.from("b") }), + ).resolves.toMatchObject({ status: "written" }); + await expect(service.files("equal-a").stat("only-b.txt")).resolves.toMatchObject({ + status: "missing", + }); + await expect(service.files("equal-b").stat("only-a.txt")).resolves.toMatchObject({ + status: "missing", + }); + + const statuses = await Promise.all( + ["a", "b"].map(async (suffix) => { + const workload = await service.run({ + workspaceId: `equal-${suffix}`, + argv: ["git", "status", "--porcelain"], + env: { PATH: process.env.PATH ?? "/usr/bin:/bin" }, + purpose: { kind: "git" }, + }); + workload.stdin.end(); + const output = await collectText(workload.stdout); + await workload.exited; + return output; + }), + ); + expect(statuses[0]).toContain("only-a.txt"); + expect(statuses[0]).not.toContain("only-b.txt"); + expect(statuses[1]).toContain("only-b.txt"); + expect(statuses[1]).not.toContain("only-a.txt"); + + await service.destroy("equal-a"); + await service.destroy("equal-b"); +}); + +test.each(["/tmp", "../outside", "outside-link"])( + "the external runtime itself rejects workspace cwd escape %s", + async (cwd) => { + const fixture = await createFixture(`cwd-${cwd.replaceAll(/[^a-z]/g, "-")}`); + const outside = path.join(fixture.root, "outside"); + await mkdir(outside); + await symlink(outside, path.join(fixture.source, "outside-link"), "dir"); + await fixture.service.create(fixture.createInput); + + const escaped = await fixture.service.run({ + workspaceId: fixture.workspaceId, + cwd, + argv: [processExecPath(), "-e", "process.exit(0)"], + env: {}, + purpose: { kind: "workspace-script", script: "cwd-confinement" }, + }); + escaped.stdin.end(); + await expect(escaped.exited).rejects.toThrow("Workspace cwd escapes its runtime root"); + await fixture.service.destroy(fixture.workspaceId); + }, +); + test("a command runtime tears down and reconstructs its bound files capability", async () => { const fixture = await createFixture("files-reconstruction"); await writeFile(path.join(fixture.source, "watched.txt"), "before\n"); @@ -251,6 +364,57 @@ test("a registered pipes-only command runtime fails closed when asked for a PTY" await fixture.service.destroy(fixture.workspaceId); }); +test.each(["success", "error", "hang"] as const)( + "a crashed pipes wrapper reaps its detached workload when the signal helper ends with %s", + async (signalHelperResult) => { + const fixture = await createFixture(`pipes-wrapper-crash-${signalHelperResult}`, false, "pty", { + crashPipeWrapper: true, + ...(signalHelperResult === "success" ? {} : { signalHelperFailure: signalHelperResult }), + }); + const pidFile = path.join(fixture.source, `crashed-pipe-${signalHelperResult}.pid`); + await fixture.service.create(fixture.createInput); + const workload = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + `require('node:fs').writeFileSync(${JSON.stringify(pidFile)},String(process.pid));setInterval(()=>{},1000)`, + ], + env: {}, + purpose: { kind: "workspace-script", script: "wrapper-crash" }, + }); + workload.stdin.end(); + + await expect(workload.exited).rejects.toThrow( + signalHelperResult === "success" + ? "pipes wrapper ended without a valid fd4 exit event" + : "cleanup failed", + ); + const workloadPid = Number(await readFile(pidFile, "utf8")); + expect(processExists(workloadPid)).toBe(false); + const later = await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "process.exit(0)"], + env: {}, + purpose: { kind: "workspace-script", script: "later-execution" }, + }); + later.stdin.end(); + await expect(later.exited).resolves.toEqual({ code: 0, signal: null }); + await fixture.service.destroy(fixture.workspaceId); + }, + 5_000, +); + +test("a command runtime protocol version mismatch fails with the authored and expected versions", async () => { + const fixture = await createFixture("version-mismatch", false, "pty", { + describeProtocolVersion: 2, + }); + + await expect(fixture.service.create(fixture.createInput)).rejects.toThrow( + "unsupported command protocol version 2; expected 1", + ); +}); + test("the fd4 workload exit remains authoritative after the wrapper exits", async () => { const fixture = await createFixture("delayed-pty-exit", false, "pty", { delayedPtyExitEvent: true, @@ -312,6 +476,71 @@ test("an invalid fd4 event rejects and terminates the wrapper workload", async ( await fixture.service.destroy(fixture.workspaceId); }); +test.each([ + ["exit-before-eof", "pipes", ["started", "exit", "eof"], "exit before eof"], + ["eof-before-started", "pipes", ["eof", "started", "exit"], "eof before started"], + ["duplicate-started", "pipes", ["started", "started"], "duplicate started"], + ["duplicate-eof", "pipes", ["started", "eof", "eof"], "duplicate eof"], + ["duplicate-exit", "pipes", ["started", "eof", "exit", "exit"], "duplicate exit"], + ["post-exit-event", "pipes", ["started", "eof", "exit", "eof"], "event after exit"], + ["pty-resize-before-started", "pty", ["resized"], "resized before started"], + ["pty-resize-after-eof", "pty", ["started", "eof", "resized"], "resized after eof"], +] as const)( + "an external %s fd4 violation rejects only after its exact workload is absent", + async (name, mode, processEventSequence, expected) => { + const pidRoot = await mkdtemp(path.join(tmpdir(), `paseo-fd4-${name}-`)); + cleanupRoots.push(pidRoot); + const pidFile = path.join(pidRoot, "workload.pid"); + const barrierFile = path.join(pidRoot, "release-events"); + const fixture = await createFixture(`fd4-${name}`, false, "pty", { + processEventSequence, + processEventPurposeKind: mode === "pty" ? "terminal" : "workspace-script", + recordWorkloadPidAt: pidFile, + processEventBarrierPath: barrierFile, + }); + await fixture.service.create(fixture.createInput); + let workloadPid: number | undefined; + try { + const workload = + mode === "pty" + ? await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "setInterval(()=>{},1000)"], + env: {}, + purpose: { kind: "terminal", terminalId: name }, + rows: 24, + cols: 80, + }) + : await fixture.service.run({ + workspaceId: fixture.workspaceId, + argv: [processExecPath(), "-e", "setInterval(()=>{},1000)"], + env: {}, + purpose: { kind: "workspace-script", script: name }, + }); + if (workload.kind === "pipes") workload.stdin.end(); + await writeFile(barrierFile, "release"); + const failure = await workload.exited.then( + () => null, + (error: unknown) => error, + ); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain(expected); + workloadPid = Number(await readFile(pidFile, "utf8")); + expect(processExists(workloadPid)).toBe(false); + } finally { + if (workloadPid && processExists(workloadPid)) { + try { + process.kill(-workloadPid, "SIGKILL"); + } catch { + // The assertion above owns the cleanup verdict; this is failure-only containment. + } + } + await fixture.service.destroy(fixture.workspaceId); + } + }, + 8_000, +); + test("a failed PTY control channel rejects and terminates the wrapper workload", async () => { const fixture = await createFixture("failed-pty-control", false, "pty", { closePtyControl: true, @@ -344,6 +573,35 @@ test("a failed PTY control channel rejects and terminates the wrapper workload", await fixture.service.destroy(fixture.workspaceId); }); +test("an invalid resize is rejected before PTY state or workload changes", async () => { + const fixture = await createFixture("invalid-resize"); + await fixture.service.create(fixture.createInput); + const terminal = await fixture.service.openTerminal({ + workspaceId: fixture.workspaceId, + argv: [ + processExecPath(), + "-e", + "process.stdin.setEncoding('utf8');process.stdin.once('data',data=>{process.stdout.write(`${data.trim()}|${process.stdout.columns}x${process.stdout.rows}`);process.exit(0)})", + ], + env: {}, + purpose: { kind: "terminal", terminalId: "invalid-resize" }, + rows: 24, + cols: 80, + }); + let output = ""; + terminal.onData((data) => { + output += data; + }); + + expect(() => terminal.resize(0, 40)).toThrow(); + terminal.resize(101, 37); + terminal.write("alive\n"); + + await expect(terminal.exited).resolves.toEqual({ code: 0, signal: null }); + expect(output).toContain("alive|101x37"); + await fixture.service.destroy(fixture.workspaceId); +}); + test.each(["error", "hang"] as const)( "PTY cleanup is bounded when the signal helper ends with %s", async (signalHelperFailure) => { @@ -451,6 +709,9 @@ async function createFixture( processExecPath(), fixtureExecutable, ...(modes === "pipes" ? ["--modes", "pipes"] : []), + ...(runtimeOptions.describeProtocolVersion === undefined + ? [] + : ["--protocol-version", String(runtimeOptions.describeProtocolVersion)]), ], helperCommand: helperCommand?.(root) ?? [processExecPath(), helperExecutable], options: { @@ -481,6 +742,12 @@ function processExecPath(): string { return process.execPath; } +async function collectText(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + function processExists(pid: number): boolean { try { process.kill(pid, 0); diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts index 0ad59664b9..fc2c2804ce 100644 --- a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts +++ b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts @@ -1,5 +1,6 @@ -import { execFileSync } from "node:child_process"; +import { execFile, execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; +import { existsSync, watch } from "node:fs"; import { chmod, copyFile, @@ -13,8 +14,9 @@ import { import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; -import { afterAll, beforeAll, expect, test, vi } from "vitest"; +import { beforeAll, expect, onTestFinished, test, vi } from "vitest"; import { createWorkspaceRuntimeService } from "./index.js"; import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; @@ -36,7 +38,7 @@ const image = "paseo-workspace-runtime-slice1:test"; const fixtureAgent = fileURLToPath( new URL("../test-utils/fixtures/workspace-runtime-acp-agent.mjs", import.meta.url), ); -const cleanupRoots: string[] = []; +const run = promisify(execFile); beforeAll(() => { execFileSync( @@ -46,10 +48,6 @@ beforeAll(() => { ); }, 120_000); -afterAll(async () => { - await Promise.all(cleanupRoots.map((root) => rm(root, { recursive: true, force: true }))); -}); - async function collect(stream: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; for await (const chunk of stream) { @@ -59,8 +57,8 @@ async function collect(stream: NodeJS.ReadableStream): Promise { } test("the command runtime owns Docker workspace materialization, pipes, and lifecycle", async () => { - const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-")); - cleanupRoots.push(root); + const 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(); @@ -84,6 +82,7 @@ test("the command runtime owns Docker workspace materialization, pipes, and life }; const service = createWorkspaceRuntimeService(options); const workspaceId = `docker-${Date.now()}`; + fixture.workspace(workspaceId); try { const workspace = await service.create({ @@ -213,15 +212,15 @@ test("the command runtime owns Docker workspace materialization, pipes, and life const recovered = createWorkspaceRuntimeService(options); await recovered.resume(workspaceId); - await expect( - recovered.run({ - workspaceId, - cwd: "../escape", - argv: ["/bin/true"], - env: {}, - purpose: { kind: "workspace-script", script: "cwd-contract" }, - }), - ).rejects.toThrow("Workspace cwd must stay within the runtime root"); + const 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"], @@ -293,8 +292,8 @@ test("the command runtime owns Docker workspace materialization, pipes, and life rows: 24, cols: 80, }); - terminalControlFailure.resize(0, 0); - await expect(terminalControlFailure.exited).rejects.toThrow("Invalid PTY size"); + terminalControlFailure.kill("SIGUSR1"); + await expect(terminalControlFailure.exited).rejects.toThrow("Unsupported signal: SIGUSR1"); expect(dockerProcesses(workspaceId)).not.toContain("sleep 30"); const committedOnly = await recovered.run({ @@ -367,11 +366,108 @@ test("the command runtime owns Docker workspace materialization, pipes, and life } finally { await service.destroy(workspaceId).catch(() => undefined); } +}, 180_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: { + type: "command", + command: [process.execPath, "--import", "tsx", runtimeExecutable], + options: { image }, + }, + }, + }); + 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 root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-port-script-")); - cleanupRoots.push(root); + 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( @@ -384,6 +480,7 @@ test("a selected Docker service port script executes in the container and leaves 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"), @@ -436,8 +533,8 @@ test("a selected Docker service port script executes in the container and leaves }, 120_000); test("public daemon and client keep Docker provider discovery, launch, files, Git, and snapshots in one runtime", async () => { - const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-daemon-")); - cleanupRoots.push(root); + 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 }); @@ -458,6 +555,7 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi }; const runtimeConfigs = { docker: runtimeConfig, "docker-configured": configuredRuntimeConfig }; 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( @@ -694,10 +792,12 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi }, 180_000); test("Docker creation validates roots and rolls back when runtime selection cannot persist", async () => { - const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-rollback-")); - cleanupRoots.push(root); + const 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, @@ -736,8 +836,8 @@ test("Docker creation validates roots and rolls back when runtime selection cann }, 120_000); test("Docker rejects a committed symlink cwd that resolves outside the selected root", async () => { - const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-symlink-")); - cleanupRoots.push(root); + const 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")); @@ -746,6 +846,7 @@ test("Docker rejects a committed symlink cwd that resolves outside the selected cwd: repo, }); const workspaceId = `docker-symlink-${Date.now()}`; + fixture.workspace(workspaceId); const runtimeIds = new Map(); const service = createWorkspaceRuntimeService({ paseoHome: path.join(root, "paseo-home"), @@ -781,12 +882,9 @@ test("Docker rejects a committed symlink cwd that resolves outside the selected purpose: { kind: "workspace-script", script: "realpath-confinement" }, }); escaped.stdin.end(); - const [escapedError, escapedExit] = await Promise.all([ - collect(escaped.stderr), - escaped.exited, - ]); - expect(escapedError).toContain("Workspace cwd escapes its root"); - expect(escapedExit.code).not.toBe(0); + const 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"], @@ -801,10 +899,11 @@ test("Docker rejects a committed symlink cwd that resolves outside the selected }, 120_000); test("the Docker runtime never adopts or destroys an unowned deterministic resource", async () => { - const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-ownership-")); - cleanupRoots.push(root); + const 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({ @@ -849,12 +948,14 @@ test("the Docker runtime never adopts or destroys an unowned deterministic resou }, 30_000); test("Docker reconciliation removes only orphaned resources with deterministic names and ownership labels", async () => { - const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-docker-reconcile-")); - cleanupRoots.push(root); + const fixture = await dockerTestFixture("paseo-runtime-docker-reconcile-"); + const root = fixture.root; const orphanWorkspaceId = `docker-orphan-${Date.now()}`; const foreignWorkspaceId = `${orphanWorkspaceId}-foreign`; const orphanVolume = dockerResourceName(orphanWorkspaceId); const foreignVolume = `${dockerResourceName(foreignWorkspaceId)}-foreign`; + fixture.volume(orphanVolume); + fixture.volume(foreignVolume); execFileSync("docker", [ "volume", "create", @@ -904,6 +1005,87 @@ test("Docker reconciliation removes only orphaned resources with deterministic n } }, 30_000); +async function dockerTestFixture(prefix: string): Promise<{ + root: string; + workspace(workspaceId: string): void; + volume(name: string): void; +}> { + const root = await mkdtemp(path.join(tmpdir(), prefix)); + 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); + 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); + 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 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); @@ -972,6 +1154,77 @@ function dockerResourceCount(kind: "ps" | "volume", workspaceId: string): number return execFileSync("docker", args, { 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"); 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 435b1a4fa0..95e1bdda78 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 @@ -3,6 +3,7 @@ import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -15,6 +16,13 @@ import { 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), +); +const helperExecutable = fileURLToPath( + new URL("../workspace-helper/executable.mjs", import.meta.url), +); afterEach(async () => { await Promise.all( @@ -22,14 +30,14 @@ afterEach(async () => { ); }); -posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", (runtimeId) => { +posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId) => { test("bound runtime exposes only process, file, and command-resolution primitives", async () => { const fixture = await createFixture(runtimeId); await fixture.service.create(fixture.createInput); const runtime = await fixture.service.bind(fixture.workspaceId); - expect(Object.keys(runtime).sort()).toEqual(["files", "homeFiles", "resolveCommand", "run"]); + expect(Object.keys(runtime).sort()).toEqual(["files", "resolveCommand", "run"]); await expect(runtime.resolveCommand("git")).resolves.toMatch(/^\//u); await expect(runtime.resolveCommand("paseo-command-that-does-not-exist")).resolves.toBeNull(); await fixture.service.destroy(fixture.workspaceId); @@ -140,7 +148,7 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", const fixture = await createFixture(runtimeId); const created = await fixture.service.create(fixture.createInput); const compatibilityCwd = - runtimeId === "local" ? fixture.runtimeRoot() : await realpath(fixture.runtimeRoot()); + runtimeId === "worktree" ? await realpath(fixture.runtimeRoot()) : fixture.runtimeRoot(); expect(created).toEqual({ workspaceId: fixture.workspaceId, runtimeId, @@ -221,7 +229,7 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", await recovered.destroy(fixture.workspaceId); expect(existsSync(fixture.repo)).toBe(true); - expect(existsSync(runtimeRoot)).toBe(runtimeId === "local"); + expect(existsSync(runtimeRoot)).toBe(runtimeId !== "worktree"); await expect( recovered.run({ workspaceId: fixture.workspaceId, @@ -315,7 +323,7 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", ]); expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(false); expect(existsSync(fixture.repo)).toBe(true); - expect(existsSync(runtimeRoot)).toBe(runtimeId === "local"); + expect(existsSync(runtimeRoot)).toBe(runtimeId !== "worktree"); }); test("registry failures leave lifecycle transitions retryable without opening an admission gap", async () => { @@ -385,14 +393,14 @@ posixDescribe.each(["local", "worktree"] as const)("%s runtime public contract", ); expect(fixture.deletingWorkspaceIds.has(fixture.workspaceId)).toBe(true); expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(true); - expect(existsSync(runtimeRoot)).toBe(runtimeId === "local"); + expect(existsSync(runtimeRoot)).toBe(runtimeId !== "worktree"); const reconstructed = createWorkspaceRuntimeService(fixture.options); await reconstructed.reconcile(); await reconstructed.destroy(fixture.workspaceId); expect(fixture.runtimeIds.has(fixture.workspaceId)).toBe(false); expect(existsSync(fixture.repo)).toBe(true); - if (runtimeId === "local") { + if (runtimeId !== "worktree") { await expect(readFile(path.join(runtimeRoot, "adopted-state.txt"), "utf8")).resolves.toBe( "keep local data\n", ); @@ -589,7 +597,7 @@ posixDescribe("fail-closed and producer cleanup", () => { }); }); -posixDescribe.each(["local", "worktree"] as const)( +posixDescribe.each(runtimeContractIds)( "%s runtime confinement and process teardown", (runtimeId) => { test("rejects a symlink cwd that resolves outside the runtime root", async () => { @@ -598,15 +606,22 @@ posixDescribe.each(["local", "worktree"] as const)( const outside = path.join(fixture.root, "outside"); await mkdir(outside); await symlink(outside, path.join(fixture.runtimeRoot(), "escape")); - await expect( - fixture.service.run({ - workspaceId: fixture.workspaceId, - cwd: "escape", - argv: ["/bin/pwd"], - env: {}, - purpose: { kind: "workspace-script", script: "cwd-contract" }, - }), - ).rejects.toThrow("Workspace cwd escapes its runtime root"); + const attempt = fixture.service.run({ + workspaceId: fixture.workspaceId, + cwd: "escape", + argv: ["/bin/pwd"], + env: {}, + purpose: { kind: "workspace-script", script: "cwd-contract" }, + }); + if (runtimeId === "fixture") { + const process = await attempt; + process.stdin.end(); + const stderr = collect(process.stderr); + await expect(process.exited).rejects.toThrow("Workspace cwd escapes its runtime root"); + await expect(stderr).resolves.toContain("Workspace cwd escapes its runtime root"); + } else { + await expect(attempt).rejects.toThrow("Workspace cwd escapes its runtime root"); + } await fixture.service.destroy(fixture.workspaceId); }); @@ -639,7 +654,7 @@ posixDescribe.each(["local", "worktree"] as const)( ); async function createFixture( - runtimeId: "local" | "worktree", + runtimeId: "local" | "worktree" | "fixture", fixtureOptions: { lifecycleRecords?: boolean } = {}, ) { const root = await temporaryRoot(runtimeId); @@ -649,9 +664,22 @@ async function createFixture( const deletingWorkspaceIds = new Set(); const lifecycleFailures = { archive: 0, restore: 0, beginDelete: 0, remove: 0 }; const worktreesRoot = path.join(root, "worktrees"); + const fixtureStateDirectory = path.join(root, "fixture-state"); + await mkdir(fixtureStateDirectory); const options: WorkspaceRuntimeOptions = { paseoHome: path.join(root, "home"), worktreesRoot, + externalRuntimes: + runtimeId === "fixture" + ? { + fixture: { + type: "command", + command: [process.execPath, fixtureRuntimeExecutable], + helperCommand: [process.execPath, helperExecutable], + options: { stateDirectory: fixtureStateDirectory }, + }, + } + : undefined, resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, persistRuntimeId: async (workspaceId, selectedRuntimeId) => { runtimeIds.set(workspaceId, selectedRuntimeId); @@ -704,7 +732,7 @@ async function createFixture( runtimeId, project: { id: `${runtimeId}-project`, source: { kind: "host-directory", path: repo } }, placement: - runtimeId === "local" + runtimeId !== "worktree" ? { kind: "existing" } : { kind: "branch", @@ -729,7 +757,7 @@ async function createFixture( createInput, service: createWorkspaceRuntimeService(options), runtimeRoot: () => - runtimeId === "local" + runtimeId !== "worktree" ? repo : listLinkedWorktrees(repo).find((cwd) => path.basename(cwd) === "runtime-worktree")!, }; diff --git a/packages/workspace-runtime-contract/README.md b/packages/workspace-runtime-contract/README.md new file mode 100644 index 0000000000..010e09d888 --- /dev/null +++ b/packages/workspace-runtime-contract/README.md @@ -0,0 +1,45 @@ +# Paseo command workspace runtime contract v1 + +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). + +## Commands + +Your configured command receives one operation: + +```text +describe +create --workspace-id +inspect --workspace-id +exec --workspace-id +signal --workspace-id --exec-id --signal +pause --workspace-id +resume --workspace-id +destroy --workspace-id +reconcile +``` + +`describe` writes `CommandRuntimeDescribeResponse` JSON to stdout. Lifecycle commands read one `CommandRuntimeLifecycleRequest` JSON value from stdin and write one `CommandRuntimeLifecycleResponse` JSON value to stdout. `create` and `resume` return `state`; `inspect` returns `inspection`; `pause`, `destroy`, and `reconcile` return `ok`. Diagnostics go to stderr. A non-zero wrapper exit means the operation failed. + +Paseo sends options from trusted daemon configuration. Secrets never belong in argv or temporary request files. The runtime must reject any `protocolVersion` other than `1` with a clear stderr diagnostic and non-zero exit. Every public v1 object schema is strict at every nested object boundary, so unknown keys—including private authority—fail instead of being stripped. Extensibility belongs only in the explicit `options`, workload `env`, and lifecycle-environment maps. Any public field change requires a new protocol version. + +## Exec file descriptors + +The workload owns fd 0, 1, and 2. Preserve their normal stdin, stdout, stderr, EOF, exit-code, and signal behavior. + +Paseo writes a single newline-terminated `spawn` envelope to fd 3. For pipes, it then closes fd 3. For PTY, fd 3 stays open and carries newline-delimited `resize` and `signal` controls. Validate a resize before changing PTY state; reject an invalid frame without launching, resizing, or signaling anything. Acknowledge every accepted resize on fd 4 with the same id before consuming following workload input. + +fd 4 carries newline-delimited process events for both modes. Its lifecycle is exactly `started` -> `eof` -> `exit`. Send `started` after the execution identity can be signaled; Paseo holds early signals until then. A PTY may send `resized` acknowledgements only after `started` and before `eof`; pipes may never send them. Duplicate, out-of-order, wrong-mode, or post-`exit` events are fatal. Close fd 4 after the single authoritative `exit`. An `error` event fails the process. A frame may arrive in partial chunks, but every frame must end in a newline. Malformed frames, a partial frame at EOF, and wrapper exit without the complete lifecycle are fatal. Paseo rejects a protocol failure only after bounded cleanup of that exact execution. PTY output remains raw workload bytes on fd 1. Closing fd 3 is the PTY control EOF; it is not another JSON control frame. + +The authoritative fd 4 `exit` preserves the workload code or terminating signal; the wrapper mirrors it when possible. Forward `SIGINT`, `SIGTERM`, and `SIGHUP` received by the wrapper to the workload process group. The separate `signal` command is the bounded cleanup path for `SIGKILL` and wrapper failure. It is idempotent, identifies only the live workload owned by `workspace-id` plus the opaque `exec-id`, and returns only after that workload is gone. Before authoritative `started`, a missing identity is not ready and must fail; after authoritative completion it is already clean. Never reuse an execution identity. + +## Lifecycle and ownership + +`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. + +The configured runtime environment must provide the compatible `paseo-workspace-helper` executable, or the daemon configuration must set `helperCommand`. Paseo launches it as an ordinary workload with purpose `workspace-helper`, already rooted at the private workspace placement. The helper receives only relative `--path` values and uses its process cwd (`.`) as confinement authority; it has no `--root` option. Runtime authors do not implement file, watch, Git, provider, script, or agent APIs: the helper handles structured files and watching, while Paseo runs Git, providers, and scripts through `exec`. + +## Compatibility + +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. diff --git a/packages/workspace-runtime-contract/examples/v1.json b/packages/workspace-runtime-contract/examples/v1.json new file mode 100644 index 0000000000..32c97e2000 --- /dev/null +++ b/packages/workspace-runtime-contract/examples/v1.json @@ -0,0 +1,90 @@ +{ + "describeResponse": { + "protocolVersion": 1, + "modes": ["pipes", "pty"], + "reconcile": true + }, + "lifecycleRequest": { + "protocolVersion": 1, + "input": { + "workspaceId": "workspace-01", + "project": { + "projectId": "project-01", + "source": { "kind": "host-directory", "path": "/source/project" } + }, + "placement": { "kind": "existing" } + }, + "options": {} + }, + "lifecycleResponse": { + "protocolVersion": 1, + "type": "state", + "state": { + "workspaceId": "workspace-01", + "lifecycle": "ready" + }, + "placement": { "cwd": "/workspace" } + }, + "pipeSpawnControl": { + "type": "spawn", + "protocolVersion": 1, + "argv": ["/bin/sh", "-c", "printf hello"], + "cwd": "packages/app", + "env": { "PATH": "/usr/bin:/bin" }, + "purpose": { "kind": "workspace-script", "script": "verify" }, + "options": {}, + "execId": "exec-pipe-01", + "stdio": { "kind": "pipes" } + }, + "ptySpawnControl": { + "type": "spawn", + "protocolVersion": 1, + "argv": ["/bin/sh", "-c", "printf hello"], + "cwd": "packages/app", + "env": { "PATH": "/usr/bin:/bin" }, + "purpose": { "kind": "workspace-script", "script": "verify" }, + "options": {}, + "execId": "exec-pty-01", + "stdio": { "kind": "pty", "rows": 24, "cols": 80, "term": "xterm-256color" } + }, + "resizeControl": { + "type": "resize", + "protocolVersion": 1, + "id": 1, + "rows": 40, + "cols": 120 + }, + "signalControl": { + "type": "signal", + "protocolVersion": 1, + "signal": "SIGTERM" + }, + "startedEvent": { "type": "started", "protocolVersion": 1 }, + "eofEvent": { "type": "eof", "protocolVersion": 1 }, + "resizedEvent": { "type": "resized", "protocolVersion": 1, "id": 1 }, + "exitEvent": { + "type": "exit", + "protocolVersion": 1, + "code": 0, + "signal": null + }, + "errorEvent": { + "type": "error", + "protocolVersion": 1, + "message": "runtime control failed" + }, + "bytes": { + "describeResponse": "{\"protocolVersion\":1,\"modes\":[\"pipes\",\"pty\"],\"reconcile\":true}\n", + "lifecycleRequest": "{\"protocolVersion\":1,\"input\":{\"workspaceId\":\"workspace-01\",\"project\":{\"projectId\":\"project-01\",\"source\":{\"kind\":\"host-directory\",\"path\":\"/source/project\"}},\"placement\":{\"kind\":\"existing\"}},\"options\":{}}\n", + "lifecycleResponse": "{\"type\":\"state\",\"protocolVersion\":1,\"state\":{\"workspaceId\":\"workspace-01\",\"lifecycle\":\"ready\"},\"placement\":{\"cwd\":\"/workspace\"}}\n", + "pipeSpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pipe-01\",\"stdio\":{\"kind\":\"pipes\"}}\n", + "ptySpawnControl": "{\"type\":\"spawn\",\"protocolVersion\":1,\"argv\":[\"/bin/sh\",\"-c\",\"printf hello\"],\"cwd\":\"packages/app\",\"env\":{\"PATH\":\"/usr/bin:/bin\"},\"purpose\":{\"kind\":\"workspace-script\",\"script\":\"verify\"},\"options\":{},\"execId\":\"exec-pty-01\",\"stdio\":{\"kind\":\"pty\",\"rows\":24,\"cols\":80,\"term\":\"xterm-256color\"}}\n", + "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", + "eofEvent": "{\"type\":\"eof\",\"protocolVersion\":1}\n", + "resizedEvent": "{\"type\":\"resized\",\"protocolVersion\":1,\"id\":1}\n", + "exitEvent": "{\"type\":\"exit\",\"protocolVersion\":1,\"code\":0,\"signal\":null}\n", + "errorEvent": "{\"type\":\"error\",\"protocolVersion\":1,\"message\":\"runtime control failed\"}\n" + } +} diff --git a/packages/workspace-runtime-contract/package.json b/packages/workspace-runtime-contract/package.json new file mode 100644 index 0000000000..d9fafb40aa --- /dev/null +++ b/packages/workspace-runtime-contract/package.json @@ -0,0 +1,33 @@ +{ + "name": "@getpaseo/workspace-runtime-contract", + "version": "0.3.1", + "description": "Versioned command workspace runtime author contract for Paseo", + "files": [ + "dist", + "examples", + "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", + "build:clean": "npm run clean && npm run build", + "test": "npm run build && tsc -p tsconfig.test.json && node --test test/*.test.mjs", + "typecheck": "tsgo -p tsconfig.json --noEmit" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/workspace-runtime-contract/src/index.ts b/packages/workspace-runtime-contract/src/index.ts new file mode 100644 index 0000000000..61464dd685 --- /dev/null +++ b/packages/workspace-runtime-contract/src/index.ts @@ -0,0 +1,348 @@ +import { z } from "zod"; + +export const COMMAND_RUNTIME_PROTOCOL_VERSION = 1 as const; + +const ProtocolVersionSchema = z.literal(COMMAND_RUNTIME_PROTOCOL_VERSION); +const EnvironmentSchema = z.record(z.string(), z.string()); +const OptionsSchema = z.record(z.string(), z.unknown()); +const 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("git"), + url: z.string(), + revision: z.string(), + subdirectory: z.string().optional(), + }) + .strict(), +]); + +const ResolvedWorktreeSourceSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("branch-off"), + baseBranch: z.string(), + branchName: z.string(), + }) + .strict(), + z.object({ kind: z.literal("checkout-branch"), branchName: z.string() }).strict(), + z + .object({ + kind: z.enum(["checkout-change-request", "checkout-github-pr"]), + forge: z.string().optional(), + changeRequestNumber: z.number().optional(), + githubPrNumber: z.number().optional(), + headRef: z.string(), + headRepositoryOwner: z.string().optional(), + baseRefName: z.string(), + checkoutRefs: z + .array(z.object({ remoteName: z.string().optional(), remoteRef: z.string() }).strict()) + .optional(), + localBranchName: z.string().optional(), + pushRemoteUrl: z.string().optional(), + trackOriginHead: z.boolean().optional(), + }) + .strict(), +]); + +export const CommandRuntimePlacementIntentSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("existing"), relativeCwd: z.string().optional() }).strict(), + z + .object({ + kind: z.literal("branch"), + branchName: z.string(), + baseRef: z.string(), + relativeCwd: z.string().optional(), + worktreeSlug: z.string().optional(), + }) + .strict(), + z + .object({ + kind: z.literal("checkout"), + ref: z.string(), + relativeCwd: z.string().optional(), + worktreeSlug: z.string().optional(), + }) + .strict(), + z + .object({ + kind: z.literal("resolved-worktree"), + source: ResolvedWorktreeSourceSchema, + worktreeSlug: z.string(), + relativeCwd: z.string().optional(), + }) + .strict(), +]); + +export const CommandRuntimeCreateInputSchema = z + .object({ + workspaceId: z.string(), + project: z + .object({ projectId: z.string(), source: CommandRuntimeProjectSourceSchema }) + .strict(), + placement: CommandRuntimePlacementIntentSchema, + markFirstAgentBranchAutoName: z.boolean().optional(), + seedPaseoConfigFrom: z.string().optional(), + }) + .strict(); + +export const CommandRuntimeLifecycleRequestSchema = z + .object({ + protocolVersion: ProtocolVersionSchema, + input: CommandRuntimeCreateInputSchema.optional(), + options: OptionsSchema, + workspaceIds: z.array(z.string()).optional(), + }) + .strict(); + +export const CommandRuntimeStateSchema = z + .object({ + workspaceId: z.string(), + lifecycle: z.enum(["ready", "paused"]), + lifecycleEnvironment: EnvironmentSchema.optional(), + }) + .strict(); + +export const CommandRuntimePlacementSchema = z + .object({ + cwd: z.string(), + hostVisiblePath: z.string().optional(), + }) + .strict(); + +export const CommandRuntimeInspectionSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("missing") }).strict(), + z + .object({ + status: z.literal("paused"), + state: CommandRuntimeStateSchema, + placement: CommandRuntimePlacementSchema, + }) + .strict(), + z + .object({ + status: z.literal("ready"), + state: CommandRuntimeStateSchema, + placement: CommandRuntimePlacementSchema, + }) + .strict(), + z.object({ status: z.literal("error"), message: z.string() }).strict(), +]); + +export const CommandRuntimeLifecycleResponseSchema = z.discriminatedUnion("type", [ + z + .object({ + type: z.literal("state"), + protocolVersion: ProtocolVersionSchema, + state: CommandRuntimeStateSchema, + placement: CommandRuntimePlacementSchema, + }) + .strict(), + z + .object({ + type: z.literal("inspection"), + protocolVersion: ProtocolVersionSchema, + inspection: CommandRuntimeInspectionSchema, + }) + .strict(), + z.object({ type: z.literal("ok"), protocolVersion: ProtocolVersionSchema }).strict(), +]); + +export const CommandRuntimeDescribeResponseSchema = z + .object({ + protocolVersion: ProtocolVersionSchema, + modes: z.array(z.enum(["pipes", "pty"])), + reconcile: z.boolean().optional().default(false), + }) + .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("git") }).strict(), + z.object({ kind: z.literal("provider-probe"), provider: z.string() }).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("setup") }).strict(), + z.object({ kind: z.literal("archive") }).strict(), +]); + +export const CommandRuntimeStdioSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("pipes") }).strict(), + z + .object({ + kind: z.literal("pty"), + rows: z.number().int().positive(), + cols: z.number().int().positive(), + term: z.string().optional(), + }) + .strict(), +]); + +export const CommandRuntimeSpawnEnvelopeSchema = z + .object({ + type: z.literal("spawn"), + protocolVersion: ProtocolVersionSchema, + argv: CommandSchema, + cwd: z.string().optional(), + env: EnvironmentSchema, + purpose: CommandRuntimeProcessPurposeSchema, + options: OptionsSchema, + execId: z.string().min(1), + stdio: CommandRuntimeStdioSchema, + }) + .strict(); + +export const CommandRuntimeControlSchema = z.discriminatedUnion("type", [ + CommandRuntimeSpawnEnvelopeSchema, + z + .object({ + type: z.literal("resize"), + protocolVersion: ProtocolVersionSchema, + id: z.number().int().nonnegative(), + rows: z.number().int().positive(), + cols: z.number().int().positive(), + }) + .strict(), + z + .object({ + type: z.literal("signal"), + protocolVersion: ProtocolVersionSchema, + signal: z.string(), + }) + .strict(), +]); + +export const CommandRuntimeProcessEventSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("started"), protocolVersion: ProtocolVersionSchema }).strict(), + z.object({ type: z.literal("eof"), protocolVersion: ProtocolVersionSchema }).strict(), + z + .object({ + type: z.literal("resized"), + protocolVersion: ProtocolVersionSchema, + id: z.number().int().nonnegative(), + }) + .strict(), + z + .object({ + type: z.literal("exit"), + protocolVersion: ProtocolVersionSchema, + code: z.number().int().nullable(), + signal: z.string().nullable(), + }) + .strict(), + z + .object({ + type: z.literal("error"), + protocolVersion: ProtocolVersionSchema, + message: z.string(), + }) + .strict(), +]); + +export interface CommandRuntimeMessageSchema { + parse(value: unknown): T; +} + +export function encodeCommandRuntimeMessage( + schema: CommandRuntimeMessageSchema, + value: unknown, +): string { + return `${JSON.stringify(schema.parse(value))}\n`; +} + +export function createCommandRuntimeMessageDecoder(schema: CommandRuntimeMessageSchema): { + push(chunk: Uint8Array | string): T[]; + finish(): void; +} { + const decoder = new TextDecoder(); + let pending = ""; + return { + push(chunk) { + pending += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); + const messages: T[] = []; + let newline = pending.indexOf("\n"); + while (newline >= 0) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (!line) throw new Error("Command runtime message frame is empty"); + messages.push(schema.parse(JSON.parse(line))); + newline = pending.indexOf("\n"); + } + return messages; + }, + finish() { + pending += decoder.decode(); + if (pending.length > 0) throw new Error("Command runtime message ended before newline"); + }, + }; +} + +export function createCommandRuntimeProcessEventDecoder(mode: "pipes" | "pty"): { + push(chunk: Uint8Array | string): CommandRuntimeProcessEvent[]; + finish(): void; +} { + const framing = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + let phase: "waiting-started" | "started" | "eof" | "exit" = "waiting-started"; + + return { + push(chunk) { + const events = framing.push(chunk); + for (const event of events) advance(event); + return events; + }, + finish() { + framing.finish(); + if (phase !== "exit") { + throw new Error( + `${mode} wrapper ended without a valid fd4 exit event: incomplete started -> eof -> exit sequence`, + ); + } + }, + }; + + function advance(event: CommandRuntimeProcessEvent): void { + if (phase === "exit") { + if (event.type === "exit") throw new Error("Command runtime returned duplicate exit"); + throw new Error(`Command runtime event after exit: ${event.type}`); + } + + if (event.type === "started") { + if (phase !== "waiting-started") + throw new Error("Command runtime returned duplicate started"); + phase = "started"; + return; + } + if (event.type === "eof") { + if (phase === "waiting-started") + throw new Error("Command runtime returned eof before started"); + if (phase === "eof") throw new Error("Command runtime returned duplicate eof"); + phase = "eof"; + return; + } + if (event.type === "exit") { + if (phase === "waiting-started") + throw new Error("Command runtime returned exit before started"); + if (phase === "started") throw new Error("Command runtime returned exit before eof"); + phase = "exit"; + return; + } + if (event.type === "resized") { + if (mode === "pipes") throw new Error("pipes runtime returned resized event"); + if (phase === "waiting-started") { + throw new Error("PTY runtime returned resized before started"); + } + if (phase === "eof") throw new Error("PTY runtime returned resized after eof"); + } + } +} + +export type CommandRuntimeLifecycleRequest = z.infer; +export type CommandRuntimeLifecycleResponse = z.infer; +export type CommandRuntimeState = z.infer; +export type CommandRuntimeInspection = z.infer; +export type CommandRuntimeSpawnEnvelope = z.infer; +export type CommandRuntimeControl = z.infer; +export type CommandRuntimeProcessEvent = z.infer; diff --git a/packages/workspace-runtime-contract/test/contract.test.mjs b/packages/workspace-runtime-contract/test/contract.test.mjs new file mode 100644 index 0000000000..b59dbdc4f9 --- /dev/null +++ b/packages/workspace-runtime-contract/test/contract.test.mjs @@ -0,0 +1,581 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { promisify } from "node:util"; + +import { + CommandRuntimeControlSchema, + CommandRuntimeDescribeResponseSchema, + CommandRuntimeLifecycleRequestSchema, + CommandRuntimeLifecycleResponseSchema, + CommandRuntimeProcessEventSchema, + CommandRuntimeProjectSourceSchema, + CommandRuntimePlacementIntentSchema, + CommandRuntimeCreateInputSchema, + CommandRuntimeInspectionSchema, + CommandRuntimePlacementSchema, + CommandRuntimeProcessPurposeSchema, + CommandRuntimeSpawnEnvelopeSchema, + CommandRuntimeStdioSchema, + CommandRuntimeStateSchema, + createCommandRuntimeMessageDecoder, + createCommandRuntimeProcessEventDecoder, + encodeCommandRuntimeMessage, +} from "../dist/index.js"; + +const run = promisify(execFile); +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const examples = JSON.parse(await readFile(path.join(packageRoot, "examples/v1.json"), "utf8")); + +test("documented lifecycle, fd3, control, and fd4 examples are exact newline-terminated bytes", () => { + for (const [name, schema] of [ + ["describeResponse", CommandRuntimeDescribeResponseSchema], + ["lifecycleRequest", CommandRuntimeLifecycleRequestSchema], + ["lifecycleResponse", CommandRuntimeLifecycleResponseSchema], + ["pipeSpawnControl", CommandRuntimeControlSchema], + ["ptySpawnControl", CommandRuntimeControlSchema], + ["resizeControl", CommandRuntimeControlSchema], + ["signalControl", CommandRuntimeControlSchema], + ["startedEvent", CommandRuntimeProcessEventSchema], + ["eofEvent", CommandRuntimeProcessEventSchema], + ["resizedEvent", CommandRuntimeProcessEventSchema], + ["exitEvent", CommandRuntimeProcessEventSchema], + ["errorEvent", CommandRuntimeProcessEventSchema], + ]) { + const bytes = encodeCommandRuntimeMessage(schema, examples[name]); + assert.equal(bytes, examples.bytes[name]); + assert.equal(bytes.endsWith("\n"), true); + assert.equal(bytes.endsWith("\n\n"), false); + } +}); + +test("lifecycle uses one exact stdin request and one exact stdout response", async () => { + assert.deepEqual(examples.lifecycleResponse.state, { + workspaceId: "workspace-01", + lifecycle: "ready", + }); + assert.doesNotMatch(examples.bytes.lifecycleResponse, /root|revision|executionDomainId/); + const child = spawn( + process.execPath, + [path.join(packageRoot, "test/pipe-fixture.mjs"), "lifecycle"], + { + stdio: ["pipe", "pipe", "pipe"], + }, + ); + child.stdin.end(examples.bytes.lifecycleRequest); + const [stdout, stderr, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + exited(child), + ]); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.equal(stdout.toString(), examples.bytes.lifecycleResponse); + assert.equal(stderr.length, 0); +}); + +test("pipes use one fd3 spawn then started/eof/exit on fd4 with raw fd0/1/2", async () => { + const child = spawn(process.execPath, [path.join(packageRoot, "test/pipe-fixture.mjs")], { + stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], + }); + child.stdin.end(Buffer.from("raw\u0000stdin\n")); + child.stdio[3].end(examples.bytes.pipeSpawnControl); + const [stdout, stderr, events, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + collect(child.stdio[4]), + exited(child), + ]); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.deepEqual(stdout, Buffer.from([0x00, 0x6f, 0x75, 0x74, 0xff])); + assert.deepEqual(stderr, Buffer.from([0x65, 0x72, 0x72, 0x00])); + assert.equal( + events.toString(), + examples.bytes.startedEvent + examples.bytes.eofEvent + examples.bytes.exitEvent, + ); + assert.deepEqual(decodeEvents(events), [ + examples.startedEvent, + examples.eofEvent, + examples.exitEvent, + ]); +}); + +test("PTY keeps fd3 open for resize and signal through EOF and emits a valid fd4 sequence", async () => { + const child = spawn(process.execPath, [path.join(packageRoot, "test/pty-fixture.mjs")], { + stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"], + }); + child.stdin.end(Buffer.from([0x74, 0x65, 0x72, 0x6d, 0x00, 0xff])); + child.stdio[3].write(examples.bytes.ptySpawnControl); + child.stdio[3].write(examples.bytes.resizeControl); + child.stdio[3].end(examples.bytes.signalControl); + const [stdout, stderr, events, exit] = await Promise.all([ + collect(child.stdout), + collect(child.stderr), + collect(child.stdio[4]), + exited(child), + ]); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.deepEqual(stdout, Buffer.from([0x74, 0x65, 0x72, 0x6d, 0x00, 0xff])); + assert.deepEqual(stderr, Buffer.from([0x70, 0x74, 0x79, 0x00])); + assert.equal( + events.toString(), + examples.bytes.startedEvent + + examples.bytes.resizedEvent + + examples.bytes.eofEvent + + '{"type":"exit","protocolVersion":1,"code":null,"signal":"SIGTERM"}\n', + ); + assert.deepEqual( + decodeEvents(events).map((event) => event.type), + ["started", "resized", "eof", "exit"], + ); +}); + +test("actual fd4 rejects malformed and wrong-version frames and detects early wrapper exit", async () => { + for (const [mode, pattern] of [ + ["malformed", SyntaxError], + ["wrong-version", /expected 1/], + ]) { + const child = spawn(process.execPath, [path.join(packageRoot, "test/pipe-fixture.mjs"), mode], { + stdio: ["ignore", "ignore", "ignore", "ignore", "pipe"], + }); + const [events, exit] = await Promise.all([collect(child.stdio[4]), exited(child)]); + assert.deepEqual(exit, { code: 0, signal: null }); + assert.throws(() => decodeEvents(events), pattern); + } + + const early = spawn( + process.execPath, + [path.join(packageRoot, "test/pipe-fixture.mjs"), "early-exit"], + { stdio: ["ignore", "ignore", "ignore", "ignore", "pipe"] }, + ); + const [events, exit] = await Promise.all([collect(early.stdio[4]), exited(early)]); + assert.equal(events.length, 0); + assert.deepEqual(exit, { code: 47, signal: null }); +}); + +test("framing accepts partial bytes and rejects malformed, early EOF, and wrong versions", () => { + const partial = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + assert.deepEqual(partial.push(examples.bytes.exitEvent.slice(0, 11)), []); + assert.deepEqual(partial.push(examples.bytes.exitEvent.slice(11)), [examples.exitEvent]); + partial.finish(); + + const malformed = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + assert.throws(() => malformed.push("{nope}\n"), SyntaxError); + + const early = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + early.push(examples.bytes.exitEvent.slice(0, -1)); + assert.throws(() => early.finish(), /ended before newline/); + + const wrongVersion = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + assert.throws( + () => wrongVersion.push('{"type":"exit","protocolVersion":2,"code":0,"signal":null}\n'), + /expected 1/, + ); +}); + +test("process event conversations enforce the complete mode-specific lifecycle", () => { + for (const [mode, events] of [ + ["pipes", [examples.startedEvent, examples.eofEvent, examples.exitEvent]], + ["pty", [examples.startedEvent, examples.resizedEvent, examples.eofEvent, examples.exitEvent]], + ]) { + const decoder = createCommandRuntimeProcessEventDecoder(mode); + assert.deepEqual( + decoder.push( + events + .map((event) => encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event)) + .join(""), + ), + events, + ); + decoder.finish(); + } + + for (const [name, mode, events, pattern] of [ + ["exit before eof", "pipes", [examples.startedEvent, examples.exitEvent], /exit before eof/], + ["eof before started", "pipes", [examples.eofEvent], /eof before started/], + ["exit before started", "pipes", [examples.exitEvent], /exit before started/], + [ + "duplicate started", + "pipes", + [examples.startedEvent, examples.startedEvent], + /duplicate started/, + ], + [ + "duplicate eof", + "pipes", + [examples.startedEvent, examples.eofEvent, examples.eofEvent], + /duplicate eof/, + ], + [ + "duplicate exit", + "pipes", + [examples.startedEvent, examples.eofEvent, examples.exitEvent, examples.exitEvent], + /duplicate exit/, + ], + [ + "post-exit lifecycle", + "pipes", + [examples.startedEvent, examples.eofEvent, examples.exitEvent, examples.eofEvent], + /event after exit/, + ], + [ + "wrong-mode resize", + "pipes", + [examples.startedEvent, examples.resizedEvent], + /pipes.*resized/, + ], + ["PTY resize before started", "pty", [examples.resizedEvent], /resized before started/], + [ + "PTY resize after eof", + "pty", + [examples.startedEvent, examples.eofEvent, examples.resizedEvent], + /resized after eof/, + ], + [ + "PTY post-exit acknowledgement", + "pty", + [examples.startedEvent, examples.eofEvent, examples.exitEvent, examples.resizedEvent], + /event after exit/, + ], + ]) { + const decoder = createCommandRuntimeProcessEventDecoder(mode); + assert.throws( + () => + decoder.push( + events + .map((event) => encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event)) + .join(""), + ), + pattern, + name, + ); + } +}); + +test("every public v1 object rejects unknown authority at every nesting level", () => { + const createInput = examples.lifecycleRequest.input; + const gitSource = { + kind: "git", + url: "https://example.invalid/repository.git", + revision: "main", + subdirectory: "project", + }; + const resolvedPlacement = { + kind: "resolved-worktree", + worktreeSlug: "change-7", + source: { + kind: "checkout-change-request", + headRef: "feature", + baseRefName: "main", + checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/heads/feature" }], + }, + }; + const readyInspection = { + status: "ready", + state: examples.lifecycleResponse.state, + placement: examples.lifecycleResponse.placement, + }; + const pausedInspection = { + status: "paused", + state: { ...examples.lifecycleResponse.state, lifecycle: "paused" }, + placement: examples.lifecycleResponse.placement, + }; + const strictCases = [ + ["describe", CommandRuntimeDescribeResponseSchema, examples.describeResponse, []], + ["create request", CommandRuntimeLifecycleRequestSchema, examples.lifecycleRequest, []], + [ + "create request input", + CommandRuntimeLifecycleRequestSchema, + examples.lifecycleRequest, + ["input"], + ], + [ + "inspect request", + CommandRuntimeLifecycleRequestSchema, + { protocolVersion: 1, options: {} }, + [], + ], + [ + "pause request", + CommandRuntimeLifecycleRequestSchema, + { protocolVersion: 1, options: {} }, + [], + ], + [ + "resume request", + CommandRuntimeLifecycleRequestSchema, + { protocolVersion: 1, options: {} }, + [], + ], + [ + "destroy request", + CommandRuntimeLifecycleRequestSchema, + { protocolVersion: 1, options: {} }, + [], + ], + ["create input", CommandRuntimeCreateInputSchema, createInput, []], + ["create project", CommandRuntimeCreateInputSchema, createInput, ["project"]], + ["create source", CommandRuntimeCreateInputSchema, createInput, ["project", "source"]], + ["create placement", CommandRuntimeCreateInputSchema, createInput, ["placement"]], + ["host source", CommandRuntimeProjectSourceSchema, createInput.project.source, []], + ["git source", CommandRuntimeProjectSourceSchema, gitSource, []], + ["existing placement", CommandRuntimePlacementIntentSchema, createInput.placement, []], + [ + "branch placement", + CommandRuntimePlacementIntentSchema, + { kind: "branch", branchName: "feature", baseRef: "main" }, + [], + ], + [ + "checkout placement", + CommandRuntimePlacementIntentSchema, + { kind: "checkout", ref: "feature" }, + [], + ], + ["resolved placement", CommandRuntimePlacementIntentSchema, resolvedPlacement, []], + ["resolved source", CommandRuntimePlacementIntentSchema, resolvedPlacement, ["source"]], + [ + "resolved checkout ref", + CommandRuntimePlacementIntentSchema, + resolvedPlacement, + ["source", "checkoutRefs", 0], + ], + ["state", CommandRuntimeStateSchema, examples.lifecycleResponse.state, []], + ["placement", CommandRuntimePlacementSchema, examples.lifecycleResponse.placement, []], + [ + "create state response", + CommandRuntimeLifecycleResponseSchema, + examples.lifecycleResponse, + [], + ], + [ + "resume state response", + CommandRuntimeLifecycleResponseSchema, + examples.lifecycleResponse, + [], + ], + [ + "state response state", + CommandRuntimeLifecycleResponseSchema, + examples.lifecycleResponse, + ["state"], + ], + [ + "state response placement", + CommandRuntimeLifecycleResponseSchema, + examples.lifecycleResponse, + ["placement"], + ], + ["ready inspection", CommandRuntimeInspectionSchema, readyInspection, []], + ["ready inspection state", CommandRuntimeInspectionSchema, readyInspection, ["state"]], + ["ready inspection placement", CommandRuntimeInspectionSchema, readyInspection, ["placement"]], + ["paused inspection", CommandRuntimeInspectionSchema, pausedInspection, []], + ["paused inspection state", CommandRuntimeInspectionSchema, pausedInspection, ["state"]], + [ + "paused inspection placement", + CommandRuntimeInspectionSchema, + pausedInspection, + ["placement"], + ], + ["missing inspection", CommandRuntimeInspectionSchema, { status: "missing" }, []], + [ + "error inspection", + CommandRuntimeInspectionSchema, + { status: "error", message: "failure" }, + [], + ], + [ + "inspection response", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "inspection", inspection: { status: "missing" } }, + [], + ], + [ + "ready inspection response state", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "inspection", inspection: readyInspection }, + ["inspection", "state"], + ], + [ + "ready inspection response placement", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "inspection", inspection: readyInspection }, + ["inspection", "placement"], + ], + [ + "nested inspection", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "inspection", inspection: { status: "missing" } }, + ["inspection"], + ], + [ + "pause ok response", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "ok" }, + [], + ], + [ + "destroy ok response", + CommandRuntimeLifecycleResponseSchema, + { protocolVersion: 1, type: "ok" }, + [], + ], + [ + "agent purpose", + CommandRuntimeProcessPurposeSchema, + { kind: "agent", agentId: "a", provider: "p" }, + [], + ], + [ + "terminal purpose", + CommandRuntimeProcessPurposeSchema, + { kind: "terminal", terminalId: "t" }, + [], + ], + ["Git purpose", CommandRuntimeProcessPurposeSchema, { kind: "git" }, []], + [ + "provider purpose", + CommandRuntimeProcessPurposeSchema, + { kind: "provider-probe", provider: "p" }, + [], + ], + ["helper purpose", CommandRuntimeProcessPurposeSchema, { kind: "workspace-helper" }, []], + ["script purpose", CommandRuntimeProcessPurposeSchema, examples.pipeSpawnControl.purpose, []], + ["setup purpose", CommandRuntimeProcessPurposeSchema, { kind: "setup" }, []], + ["archive purpose", CommandRuntimeProcessPurposeSchema, { kind: "archive" }, []], + ["pipes stdio", CommandRuntimeStdioSchema, { kind: "pipes" }, []], + ["PTY stdio", CommandRuntimeStdioSchema, examples.ptySpawnControl.stdio, []], + ["spawn", CommandRuntimeSpawnEnvelopeSchema, examples.pipeSpawnControl, []], + ["spawn purpose", CommandRuntimeSpawnEnvelopeSchema, examples.pipeSpawnControl, ["purpose"]], + ["spawn stdio", CommandRuntimeSpawnEnvelopeSchema, examples.pipeSpawnControl, ["stdio"]], + ["resize control", CommandRuntimeControlSchema, examples.resizeControl, []], + ["signal control", CommandRuntimeControlSchema, examples.signalControl, []], + ["started event", CommandRuntimeProcessEventSchema, examples.startedEvent, []], + ["eof event", CommandRuntimeProcessEventSchema, examples.eofEvent, []], + ["resized event", CommandRuntimeProcessEventSchema, examples.resizedEvent, []], + ["exit event", CommandRuntimeProcessEventSchema, examples.exitEvent, []], + ["error event", CommandRuntimeProcessEventSchema, examples.errorEvent, []], + ]; + for (const [name, schema, value, nestingPath] of strictCases) { + for (const field of ["root", "unknownV1Field"]) { + assert.throws( + () => schema.parse(inject(value, nestingPath, field, "/private")), + /unrecognized key/i, + `${name} accepted ${field}`, + ); + } + } +}); + +test("v1 extension maps stay explicit and future versions fail by protocol version", () => { + assert.deepEqual( + CommandRuntimeLifecycleRequestSchema.parse({ + protocolVersion: 1, + options: { root: "runtime-private-option", vendorExtension: { nested: true } }, + }).options, + { root: "runtime-private-option", vendorExtension: { nested: true } }, + ); + assert.deepEqual( + CommandRuntimeStateSchema.parse({ + ...examples.lifecycleResponse.state, + lifecycleEnvironment: { root: "environment-value", VENDOR_VALUE: "accepted" }, + }).lifecycleEnvironment, + { root: "environment-value", VENDOR_VALUE: "accepted" }, + ); + assert.deepEqual( + CommandRuntimeSpawnEnvelopeSchema.parse({ + ...examples.pipeSpawnControl, + env: { root: "workload-environment-value" }, + options: { vendorExtension: true }, + }), + { + ...examples.pipeSpawnControl, + env: { root: "workload-environment-value" }, + options: { vendorExtension: true }, + }, + ); + for (const [schema, value] of [ + [CommandRuntimeDescribeResponseSchema, examples.describeResponse], + [CommandRuntimeLifecycleRequestSchema, examples.lifecycleRequest], + [CommandRuntimeLifecycleResponseSchema, examples.lifecycleResponse], + [CommandRuntimeControlSchema, examples.pipeSpawnControl], + [CommandRuntimeProcessEventSchema, examples.startedEvent], + ]) { + assert.throws(() => schema.parse({ ...value, protocolVersion: 2 }), /expected 1/); + } +}); + +test("public state schemas reject physical authority under computed and aliased spellings", () => { + const root = ["ro", "ot"].join(""); + const state = { workspaceId: "workspace-01", lifecycle: "ready", [root]: "/private" }; + const schemaAlias = CommandRuntimeStateSchema; + assert.throws(() => schemaAlias.parse(state), /unrecognized key/i); + assert.throws( + () => + CommandRuntimeLifecycleResponseSchema.parse({ + ...examples.lifecycleResponse, + state: { ...examples.lifecycleResponse.state, [root]: "/private" }, + }), + /unrecognized key/i, + ); +}); + +test("every export condition resolves from a packed standalone install", async (t) => { + const root = await mkdtemp(path.join(tmpdir(), "workspace-runtime-contract-pack-")); + t.after(() => rm(root, { recursive: true, force: true })); + const pack = JSON.parse( + ( + await run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", root], { + cwd: packageRoot, + }) + ).stdout, + ); + const archive = path.join(root, pack[0].filename); + const installRoot = path.join(root, "standalone"); + await mkdir(installRoot); + await run("npm", ["install", "--ignore-scripts", "--no-package-lock", archive], { + cwd: installRoot, + }); + const installed = path.join(installRoot, "node_modules/@getpaseo/workspace-runtime-contract"); + const manifest = JSON.parse(await readFile(path.join(installed, "package.json"), "utf8")); + for (const target of Object.values(manifest.exports["."])) { + await stat(path.join(installed, target)); + } + const imported = await import(pathToFileURL(path.join(installed, manifest.exports["."].default))); + assert.equal(imported.COMMAND_RUNTIME_PROTOCOL_VERSION, 1); +}); + +async function collect(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks); +} + +function exited(child) { + return new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolve({ code, signal })); + }); +} + +function decodeEvents(bytes) { + const decoder = createCommandRuntimeMessageDecoder(CommandRuntimeProcessEventSchema); + const split = Math.min(13, bytes.length); + const events = [ + ...decoder.push(bytes.subarray(0, split)), + ...decoder.push(bytes.subarray(split)), + ]; + decoder.finish(); + return events; +} + +function inject(value, nestingPath, field, injected) { + const copy = structuredClone(value); + let target = copy; + for (const segment of nestingPath) target = target[segment]; + target[field] = injected; + return copy; +} diff --git a/packages/workspace-runtime-contract/test/pipe-fixture.mjs b/packages/workspace-runtime-contract/test/pipe-fixture.mjs new file mode 100644 index 0000000000..0c9fd1179e --- /dev/null +++ b/packages/workspace-runtime-contract/test/pipe-fixture.mjs @@ -0,0 +1,56 @@ +import { readFileSync, writeSync } from "node:fs"; + +import { + CommandRuntimeLifecycleRequestSchema, + CommandRuntimeLifecycleResponseSchema, + CommandRuntimeProcessEventSchema, + CommandRuntimeSpawnEnvelopeSchema, + encodeCommandRuntimeMessage, +} from "../dist/index.js"; + +const examples = JSON.parse(readFileSync(new URL("../examples/v1.json", import.meta.url), "utf8")); +const mode = process.argv[2] ?? "conversation"; + +if (mode === "lifecycle") { + const input = await read(process.stdin); + CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(input)); + process.stdout.write( + encodeCommandRuntimeMessage(CommandRuntimeLifecycleResponseSchema, examples.lifecycleResponse), + ); +} else if (mode === "early-exit") { + process.exitCode = 47; +} else if (mode === "malformed") { + writeSync(4, "{malformed}\n"); +} else if (mode === "wrong-version") { + writeSync(4, '{"type":"started","protocolVersion":2}\n'); +} else { + const fd3 = readFileSync(3, "utf8"); + const envelope = CommandRuntimeSpawnEnvelopeSchema.parse(JSON.parse(fd3)); + if (envelope.stdio.kind !== "pipes") throw new Error("Expected a pipes spawn envelope"); + const stdin = await read(process.stdin); + process.stdout.write(Buffer.from([0x00, 0x6f, 0x75, 0x74, 0xff])); + process.stderr.write(Buffer.from([0x65, 0x72, 0x72, 0x00])); + await writeEvent(examples.startedEvent, true); + await writeEvent(examples.eofEvent); + await writeEvent(examples.exitEvent); + if (fd3 !== examples.bytes.pipeSpawnControl || stdin !== "raw\u0000stdin\n") { + process.exitCode = 9; + } +} + +async function writeEvent(event, partial = false) { + const bytes = encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event); + if (!partial) { + writeSync(4, bytes); + return; + } + writeSync(4, bytes.slice(0, 13)); + await new Promise((resolve) => setImmediate(resolve)); + writeSync(4, bytes.slice(13)); +} + +async function read(stream) { + const chunks = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/packages/workspace-runtime-contract/test/pty-fixture.mjs b/packages/workspace-runtime-contract/test/pty-fixture.mjs new file mode 100644 index 0000000000..dcbd6ccbdb --- /dev/null +++ b/packages/workspace-runtime-contract/test/pty-fixture.mjs @@ -0,0 +1,42 @@ +import { createInterface } from "node:readline"; +import { Socket } from "node:net"; + +import { + CommandRuntimeControlSchema, + CommandRuntimeProcessEventSchema, + encodeCommandRuntimeMessage, +} from "../dist/index.js"; + +const control = new Socket({ fd: 3, readable: true, writable: false }); +const events = new Socket({ fd: 4, readable: false, writable: true }); +const lines = createInterface({ input: control, crlfDelay: Infinity }); +const received = []; + +process.stdin.on("data", (chunk) => process.stdout.write(chunk)); +process.stderr.write(Buffer.from([0x70, 0x74, 0x79, 0x00])); + +for await (const line of lines) { + const message = CommandRuntimeControlSchema.parse(JSON.parse(line)); + received.push(message); + if (received.length === 1) { + if (message.type !== "spawn" || message.stdio.kind !== "pty") { + throw new Error("Expected a PTY spawn envelope"); + } + writeEvent({ type: "started", protocolVersion: 1 }); + } else if (message.type === "resize") { + writeEvent({ type: "resized", protocolVersion: 1, id: message.id }); + } else if (message.type !== "signal") { + throw new Error(`Unexpected PTY control: ${message.type}`); + } +} + +if (received.map((message) => message.type).join(",") !== "spawn,resize,signal") { + throw new Error("Expected spawn, resize, signal, then fd3 EOF"); +} +writeEvent({ type: "eof", protocolVersion: 1 }); +writeEvent({ type: "exit", protocolVersion: 1, code: null, signal: "SIGTERM" }); +events.end(); + +function writeEvent(event) { + events.write(encodeCommandRuntimeMessage(CommandRuntimeProcessEventSchema, event)); +} diff --git a/packages/workspace-runtime-contract/test/public-types.ts b/packages/workspace-runtime-contract/test/public-types.ts new file mode 100644 index 0000000000..f23f5ced2f --- /dev/null +++ b/packages/workspace-runtime-contract/test/public-types.ts @@ -0,0 +1,26 @@ +import type { CommandRuntimeState } from "../src/index.js"; + +interface ExpectedState { + workspaceId: string; + lifecycle: "ready" | "paused"; + lifecycleEnvironment?: Record; +} + +type Equal = + (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false; +const exactStateShape: Equal = true; +void exactStateShape; + +declare const state: CommandRuntimeState; +declare const inspection: { state: CommandRuntimeState }; +const rootKey = ["ro", "ot"].join(""); +// @ts-expect-error Physical placement is not part of public runtime state. +void state.root; +// @ts-expect-error Constant aliases cannot recover private placement. +void state["root" as const]; +// @ts-expect-error Computed string access has no public state index signature. +void state[rootKey]; +// @ts-expect-error Chained bracket access cannot cross the public state boundary. +void inspection["state"]["root"]; diff --git a/packages/workspace-runtime-contract/tsconfig.json b/packages/workspace-runtime-contract/tsconfig.json new file mode 100644 index 0000000000..9e5d1f3e76 --- /dev/null +++ b/packages/workspace-runtime-contract/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "lib": ["ES2022", "DOM"], + "declaration": true, + "incremental": false, + "noEmit": false + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/workspace-runtime-contract/tsconfig.test.json b/packages/workspace-runtime-contract/tsconfig.test.json new file mode 100644 index 0000000000..d28701f647 --- /dev/null +++ b/packages/workspace-runtime-contract/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "." + }, + "include": ["src/**/*.ts", "test/public-types.ts"] +} diff --git a/scripts/check-workspace-runtime-boundaries.mjs b/scripts/check-workspace-runtime-boundaries.mjs new file mode 100644 index 0000000000..4dab903e54 --- /dev/null +++ b/scripts/check-workspace-runtime-boundaries.mjs @@ -0,0 +1,228 @@ +#!/usr/bin/env node + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import ts from "typescript"; + +const SERVER_ROOT = "packages/server/src/server"; +const FIXTURE_ROOT = "packages/fixture-workspace-runtime"; +const migratedOwners = [ + "/session/files/", + "/session/git-mutation/", + "/session/provider/", + "/session/workspace-git-observer/", + "/session/workspace-provisioning/", + "/session/workspace-scripts/", +]; +const hostModules = new Set(["child_process", "fs", "fs/promises", "module", "which"]); + +export async function findWorkspaceBoundaryViolations(repoRoot) { + const files = [ + ...(await collectSources(path.join(repoRoot, SERVER_ROOT))), + ...(await collectSources(path.join(repoRoot, FIXTURE_ROOT))), + ]; + const modules = await Promise.all( + files.map(async (filename) => { + const source = await readFile(filename, "utf8"); + return { filename, sourceFile: parseSource(filename, source) }; + }), + ); + const publicOwners = new Set( + modules + .filter(({ sourceFile }) => staticSpecifiers(sourceFile).some(isPublicEntryPoint)) + .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 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")); + } + } + } + return violations.sort((left, right) => + `${left.file}:${left.import}:${left.rule}`.localeCompare( + `${right.file}:${right.import}:${right.rule}`, + ), + ); +} + +function parseSource(filename, source) { + return ts.createSourceFile( + filename, + source, + ts.ScriptTarget.Latest, + true, + filename.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); +} + +function staticSpecifiers(sourceFile) { + const specifiers = []; + for (const statement of sourceFile.statements) { + if ( + (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + specifiers.push(statement.moduleSpecifier.text); + } + } + return specifiers; +} + +function moduleLoads(sourceFile) { + const loads = []; + visit(sourceFile); + return loads; + + function visit(node) { + if ( + ts.isCallExpression(node) && + (node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === "require")) + ) { + const argument = node.arguments.length === 1 ? node.arguments[0] : undefined; + loads.push({ specifier: argument && ts.isStringLiteral(argument) ? argument.text : null }); + } + ts.forEachChild(node, visit); + } +} + +function isPublicEntryPoint(specifier) { + return /workspace-(?:runtime|helper)\/index\.(?:js|ts)$/.test(specifier); +} + +function ownsMigratedSurface(importer) { + return !isTestFile(importer) && migratedOwners.some((owner) => importer.includes(owner)); +} + +function isHostCapability(specifier) { + const normalized = specifier.startsWith("node:") ? specifier.slice(5) : specifier; + return ( + hostModules.has(normalized) || /(?:^|\/)utils\/run-git-command\.(?:js|ts)$/.test(normalized) + ); +} + +function isForbiddenInternal(importer, specifier) { + const target = resolveSpecifier(importer, specifier); + if (!target || !target.includes("/internal/")) return false; + if (target.includes("/workspace-runtime/command/internal/")) { + return !( + importer.endsWith("/workspace-runtime/command/index.ts") || + importer.includes("/workspace-runtime/command/internal/") + ); + } + if (target.includes("/workspace-runtime/git-observation/internal/")) { + return !( + importer.endsWith("/workspace-runtime/git-observation/index.ts") || + importer.includes("/workspace-runtime/git-observation/internal/") || + importer.endsWith("/workspace-runtime/internal/service.ts") + ); + } + if (target.includes("/workspace-runtime/internal/")) { + return !( + importer.endsWith("/workspace-runtime/index.ts") || + importer.includes("/workspace-runtime/internal/") + ); + } + 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 false; +} + +function isExternalEscape(importer, specifier) { + const normalized = specifier.startsWith("node:") ? specifier.slice(5) : specifier; + if (normalized === "module") return true; + if (specifier.startsWith("node:") || hostModules.has(specifier) || specifier === "node-pty") { + return false; + } + if (specifier === "@getpaseo/workspace-runtime-contract") 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}/`); + } + return ( + specifier === "@getpaseo/server" || + specifier.startsWith("@getpaseo/server/") || + specifier.includes("packages/server/") || + specifier.includes("/internal/") + ); +} + +function resolveSpecifier(importer, specifier) { + if (specifier.startsWith(".")) { + return path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier)); + } + return specifier.replace(/^@getpaseo\/server\/?/, `${SERVER_ROOT}/`); +} + +function isTestFile(filename) { + return /\.(?:test|spec)\.[^.]+$/.test(filename); +} + +function violation(file, imported, rule) { + return { + file, + import: imported, + rule, + message: `${file}: ${rule} forbids ${imported}`, + }; +} + +async function collectSources(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (error.code === "ENOENT") return []; + throw error; + } + const nested = await Promise.all( + entries.map((entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) return collectSources(target); + return [".js", ".mjs", ".ts", ".tsx"].includes(path.extname(entry.name)) ? [target] : []; + }), + ); + return nested.flat(); +} + +function toRepoPath(repoRoot, filename) { + return path.relative(repoRoot, filename).split(path.sep).join("/"); +} + +async function main() { + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + const violations = await findWorkspaceBoundaryViolations(repoRoot); + for (const item of violations) process.stderr.write(`${item.message}\n`); + if (violations.length > 0) process.exitCode = 1; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/ci-workflow.test.mjs b/scripts/ci-workflow.test.mjs index 8ba01afbe6..72eb540639 100644 --- a/scripts/ci-workflow.test.mjs +++ b/scripts/ci-workflow.test.mjs @@ -119,6 +119,7 @@ test("focused contracts stay inside existing required checks", () => { assert.doesNotMatch(changes, /Install dependencies|npm run build/); assert.match(server, /test:hub-cli-contract/); + assert.match(server, /npm test --workspace=@getpaseo\/workspace-runtime-contract/); assert.match(server, /npm run test --workspace=@getpaseo\/server/); assert.ok(!jobs.has("hub-cli-contract")); @@ -159,7 +160,12 @@ test("PR routing declares stable behavior ownership", () => { ], quality: ["**/*.{cjs,js,json,jsx,mjs,ts,tsx}", "packages/expo-two-way-audio/**"], hub: ["packages/cli/src/commands/hub/**", "packages/server/src/server/hub/**"], - server: ["packages/server/**", "packages/app/e2e/support/fixtures/recording.*"], + server: [ + "packages/server/**", + "packages/fixture-workspace-runtime/**", + "packages/workspace-runtime-contract/**", + "packages/app/e2e/support/fixtures/recording.*", + ], desktop: [ "packages/desktop/**", "packages/app/src/desktop/**", diff --git a/scripts/workspace-runtime-boundaries.test.mjs b/scripts/workspace-runtime-boundaries.test.mjs new file mode 100644 index 0000000000..30293fbf76 --- /dev/null +++ b/scripts/workspace-runtime-boundaries.test.mjs @@ -0,0 +1,214 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import test from "node:test"; +import assert from "node:assert/strict"; +import { promisify } from "node:util"; + +import { findWorkspaceBoundaryViolations } from "./check-workspace-runtime-boundaries.mjs"; + +const run = promisify(execFile); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const oxlint = path.join(repoRoot, "node_modules/.bin/oxlint"); + +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")); + await source( + root, + "packages/server/src/server/session/workspace-scripts/forbidden.ts", + 'import { spawn } from "child_process";\n' + + 'import { readFile } from "node:fs/promises";\n' + + 'import { createRequire as makeRequire } from "node:module";\n' + + "const renamedLoader = makeRequire(import.meta.url);\n" + + 'renamedLoader("child_process");\n' + + 'renamedLoader("fs");\n' + + 'renamedLoader("@getpaseo/server/workspace-runtime/internal/service.js");\n' + + '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' + + "void spawn; void readFile;\n", + ); + await source( + root, + "packages/fixture-workspace-runtime/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', + ); + + const error = await captureFailure(run(oxlint, ["packages"], { 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, /workspace-runtime\/internal/); + assert.match(output, /workspace-runtime\/command\/internal/); + assert.match(output, /git-observation\/internal/); + assert.match(output, /workspace-helper\/internal/); +}); + +test("the AST guard rejects computed loading and dynamic host/internal bypasses", async (t) => { + const root = await fixtureRoot(t); + await source( + root, + "packages/server/src/server/session/workspace-scripts/forbidden.ts", + 'import type { WorkspaceRuntimeService } from "../../workspace-runtime/index.js";\n' + + 'require("fs");\n' + + 'import("node:child_process");\n' + + 'const { createRequire: makeLoader } = require("module");\n' + + 'const renamedLoader = makeLoader(import.meta.url); renamedLoader("fs");\n' + + 'require("node:" + "fs/promises");\n' + + "import(`@getpaseo/server/${segment}`);\n", + ); + await source( + root, + "packages/server/src/server/session/business.ts", + 'require("@getpaseo/server/workspace-runtime/command/internal/command-runtime.js");\n', + ); + await source( + root, + "packages/fixture-workspace-runtime/src/forbidden.mjs", + 'require("../../../packages/server/src/server/workspace-runtime/internal/service.js");\n' + + 'import("@getpaseo/" + packageName);\n', + ); + + const violations = await findWorkspaceBoundaryViolations(root); + assert.deepEqual( + violations.map(({ rule }) => rule).sort(), + [ + "external-runtime-contract", + "module-entrypoint", + "module-entrypoint", + "nonliteral-module-load", + "nonliteral-module-load", + "nonliteral-module-load", + "workspace-host-access", + "workspace-host-access", + "workspace-host-access", + ].sort(), + ); +}); + +test("owned integrations, explicit legacy code, and fixture contract imports pass", async (t) => { + const root = await fixtureRoot(t); + await copyFile(path.join(repoRoot, ".oxlintrc.json"), path.join(root, ".oxlintrc.json")); + await source( + root, + "packages/server/src/server/workspace-runtime/index.ts", + 'import { service } from "./internal/service.js"; void service;\n', + ); + await source( + root, + "packages/server/src/server/workspace-runtime/command/index.ts", + 'export * from "./internal/command-runtime.js";\n', + ); + await source( + 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' + + "export const service = [git, helper];\n", + ); + await source( + root, + "packages/server/src/server/workspace-git-service.ts", + 'import { readFile } from "fs/promises"; void readFile;\n', + ); + await source( + root, + "packages/fixture-workspace-runtime/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', + ); + await source( + root, + "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", + ); + + assert.deepEqual(await findWorkspaceBoundaryViolations(root), []); + try { + await run(oxlint, ["packages"], { cwd: root }); + } catch (error) { + assert.fail(`${error.stdout ?? ""}${error.stderr ?? ""}`); + } +}); + +test("strict public schemas and helper argv reject root authority independent of syntax", async (t) => { + await run("npm", ["run", "build", "--workspace=@getpaseo/workspace-runtime-contract"], { + cwd: repoRoot, + }); + const contract = await import( + pathToFileURL(path.join(repoRoot, "packages/workspace-runtime-contract/dist/index.js")) + ); + const key = ["ro", "ot"].join(""); + const state = { workspaceId: "workspace-01", lifecycle: "ready", [key]: "/private" }; + const schemaAlias = contract.CommandRuntimeStateSchema; + assert.throws(() => schemaAlias.parse(state), /unrecognized key/i); + assert.throws( + () => + contract.CommandRuntimeLifecycleResponseSchema.parse({ + type: "state", + protocolVersion: 1, + state: { ...state }, + placement: { cwd: "/workspace" }, + }), + /unrecognized key/i, + ); + + const root = await fixtureRoot(t); + const helperError = await captureFailure( + run( + process.execPath, + [ + path.join(repoRoot, "packages/server/src/server/workspace-helper/executable.mjs"), + "fs-stat", + `--${key}`, + "/private", + "--path", + ".", + ], + { cwd: root }, + ), + ); + assert.match(helperError.stderr, /Unknown workspace-helper argument: --root/); +}); + +async function captureFailure(promise) { + try { + await promise; + } catch (error) { + return error; + } + assert.fail("Expected command to fail"); +} + +async function fixtureRoot(t) { + const root = await mkdtemp(path.join(tmpdir(), "workspace-boundaries-")); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} + +async function source(root, relativePath, contents) { + const filename = path.join(root, relativePath); + await mkdir(path.dirname(filename), { recursive: true }); + await writeFile(filename, contents); +}