From d03e4298ab547f4d04c41ac85c9c9b184232d61e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 12 Aug 2026 18:28:06 +0200 Subject: [PATCH] feat(workspaces): accept Docker runtime journeys Exercise probe, creation, terminals, file observation, Git, scripts, and agent edits through the Desktop Docker runtime while proving host decoys remain untouched. Selected-runtime creation has a longer request budget because materialization and setup can legitimately exceed the legacy RPC ceiling; legacy creation keeps its existing timeout. Materializing records remain durable but private until final placement is published. Scope Docker resources to the daemon owner and validate both container and volume before every lifecycle operation and creation race. Explicit Local and Worktree selection remains behaviorally unchanged, including reusable Local script terminals and the shared common-Git watcher. Real-provider manual QA uses ignored checkout-local configuration and is intentionally not committed. --- .github/workflows/docker.yml | 17 +- .../app/e2e/support/helpers/e2e-worker.ts | 28 +- .../support/helpers/new-workspace-runtime.ts | 457 +++++++++++++- .../app/e2e/support/helpers/new-workspace.ts | 3 +- .../app/e2e/support/helpers/seed-client.ts | 5 + packages/client/src/daemon-client.test.ts | 56 ++ packages/client/src/daemon-client.ts | 2 + .../desktop/e2e/new-workspace-docker.spec.ts | 162 +++++ .../desktop/e2e/new-workspace-runtime.spec.ts | 52 +- packages/desktop/playwright.config.ts | 2 + packages/docker-workspace-runtime/Dockerfile | 14 +- .../docker-workspace-runtime/src/index.ts | 308 +++++++--- packages/server/src/server/bootstrap.ts | 8 - .../src/server/daemon-config-store.test.ts | 24 + ...space-runtime-characterization.e2e.test.ts | 35 ++ .../src/server/persisted-config.test.ts | 40 ++ .../server/src/server/persisted-config.ts | 16 + .../workspace-provisioning-service.test.ts | 24 + .../workspace-provisioning-service.ts | 5 +- .../workspace-scripts-service.test.ts | 2 +- .../workspace-scripts-service.ts | 2 +- .../src/server/workspace-registry.test.ts | 33 +- .../server/src/server/workspace-registry.ts | 28 +- .../command/internal/command-runtime.ts | 1 + .../server/workspace-runtime/drivers/index.ts | 1 + .../internal/integration.test.ts | 40 ++ .../git-observation/internal/integration.ts | 34 +- .../src/server/workspace-runtime/index.ts | 24 +- .../internal/local-runtime.ts | 1 + .../workspace-runtime/internal/service.ts | 1 + .../internal/worktree-runtime.ts | 1 + .../workspace-runtime.docker.e2e.test.ts | 557 ++++++++++++++++-- .../src/server/worktree-bootstrap.test.ts | 85 +++ .../server/src/server/worktree-bootstrap.ts | 43 +- .../terminal-workspace-runtime.posix.test.ts | 16 + 35 files changed, 1974 insertions(+), 153 deletions(-) create mode 100644 packages/desktop/e2e/new-workspace-docker.spec.ts create mode 100644 packages/server/src/server/workspace-runtime/git-observation/internal/integration.test.ts diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7b4df067f2..f72882e36b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -17,6 +17,7 @@ on: - "packages/cli/**" - "packages/client/**" - "packages/docker-workspace-runtime/**" + - "packages/desktop/**" - "packages/fixture-workspace-runtime/**" - "packages/workspace-runtime-contract/**" - "packages/expo-two-way-audio/**" @@ -175,12 +176,24 @@ jobs: - name: Install dependencies run: node scripts/npm-retry.mjs ci - - name: Build server dependencies - run: npm run build:server-deps + - name: Build server stack + run: npm run build:server + + - name: Build Docker acceptance image + run: docker build --file packages/docker-workspace-runtime/Dockerfile --tag paseo-workspace-runtime-e2e:test . - name: Test workspace runtime contract run: npm run test:workspace-runtime:docker --workspace=@getpaseo/server + - name: Install Playwright browser + run: npx playwright install chromium + + - name: Test Docker Desktop acceptance journey + run: npm run test:e2e:renderer --workspace=@getpaseo/desktop -- e2e/new-workspace-docker.spec.ts --workers=1 + env: + E2E_DOCKER_ACCEPTANCE: "1" + E2E_DOCKER_WORKSPACE_IMAGE: paseo-workspace-runtime-e2e:test + publish: needs: setup if: needs.setup.outputs.publish == 'true' diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index f3caba2d12..8ddde1bf03 100644 --- a/packages/app/e2e/support/helpers/e2e-worker.ts +++ b/packages/app/e2e/support/helpers/e2e-worker.ts @@ -150,6 +150,11 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise __dirname, "../../../../server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs", ); + const dockerAcceptance = process.env.E2E_DOCKER_ACCEPTANCE === "1"; + const dockerImage = process.env.E2E_DOCKER_WORKSPACE_IMAGE; + if (dockerAcceptance && !dockerImage) { + throw new Error("E2E_DOCKER_WORKSPACE_IMAGE is required for Docker acceptance"); + } await mkdir(stateDirectory, { recursive: true }); await writeFile( configPath, @@ -163,18 +168,39 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise claude: { ...existing.agents?.providers?.claude, enabled: false }, codex: { ...existing.agents?.providers?.codex, enabled: false }, copilot: { ...existing.agents?.providers?.copilot, enabled: false }, - opencode: { ...existing.agents?.providers?.opencode, enabled: false }, + opencode: { + ...existing.agents?.providers?.opencode, + enabled: dockerAcceptance, + }, pi: { ...existing.agents?.providers?.pi, enabled: false }, "fixture-agent": { extends: "acp", label: "Fixture Agent", command: [process.execPath, "./.paseo-fixture-agent.mjs"], models: [{ id: "fixture-model", label: "Fixture Model", isDefault: true }], + enabled: !dockerAcceptance, }, + ...(dockerAcceptance + ? { + "docker-fixture": { + extends: "acp", + label: "Docker Fixture", + command: ["node", "/usr/local/bin/paseo-fixture-agent.mjs"], + }, + } + : {}), }, }, workspaceRuntimes: { ...existing.workspaceRuntimes, + ...(dockerAcceptance + ? { + docker: { + type: "docker", + image: dockerImage, + }, + } + : {}), fixture: { type: "command", label: "Fixture", diff --git a/packages/app/e2e/support/helpers/new-workspace-runtime.ts b/packages/app/e2e/support/helpers/new-workspace-runtime.ts index f54a1c812c..e346eac56e 100644 --- a/packages/app/e2e/support/helpers/new-workspace-runtime.ts +++ b/packages/app/e2e/support/helpers/new-workspace-runtime.ts @@ -1,5 +1,8 @@ -import { access, readFile, readdir } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { access, readFile, readdir, stat, writeFile } from "node:fs/promises"; import path from "node:path"; +import { promisify } from "node:util"; import { expect, type Page } from "@playwright/test"; import { gotoAppShell } from "./app"; import { getE2EDaemonPort } from "./daemon-port"; @@ -13,7 +16,9 @@ import { } from "./new-workspace"; import { connectSeedClient } from "./seed-client"; import { getServerId } from "./server-id"; +import { getTerminalBufferText } from "./terminal-perf"; import { createTempDirectory, createTempGitRepo } from "./workspace"; +import { clickFirstTerminalTab } from "./workspace-tabs"; export interface SeededRuntimeProject { projectId: string; @@ -23,6 +28,27 @@ export interface SeededRuntimeProject { cleanup(): Promise; } +interface HostDecoySnapshot { + content: string; + mtimeMs: number; +} + +export interface SeededDockerRuntimeProject extends SeededRuntimeProject { + hostMutationPath: string; + expectHostDecoysUntouched(): Promise; + mutateHostAfterDockerCreation(): Promise; +} + +const run = promisify(execFile); +const DOCKER_CREATED_FILE = "made-in-container.txt"; +const DOCKER_SCRIPT_FILE = "workspace-script-output.txt"; +const DOCKER_AGENT_FILE = "stdio-agent-output.txt"; +const DOCKER_RUNTIME_LABELS = { + runtime: "sh.paseo.runtime", + runtimeOwner: "sh.paseo.runtime-owner", + workspaceId: "sh.paseo.workspace-id", +} as const; + const SETUP_MARKER = "runtime-user-setup-ran.txt"; export async function seedGitProjectForRuntime( @@ -37,6 +63,61 @@ export async function seedGitProjectForRuntime( return seedRuntimeProject(repo); } +export async function seedGitProjectWithDockerDecoys(): Promise { + const repo = await createTempGitRepo("runtime-docker-", { + paseoConfig: { + worktree: { + setup: ['node -e "setTimeout(() => {}, 65000)"'], + }, + scripts: { + "docker-proof": { + command: "pwd > workspace-script-output.txt; sleep 10", + }, + }, + }, + files: [ + { path: "committed.txt", content: "committed host baseline\n" }, + { path: "provider-model.txt", content: "docker-fixture-model\n" }, + { path: "host-mutation.txt", content: "committed before Docker creation\n" }, + ], + }); + const decoyContents = new Map([ + [DOCKER_CREATED_FILE, "host decoy: terminal must not overwrite this\n"], + [DOCKER_SCRIPT_FILE, "host decoy: script must not overwrite this\n"], + [DOCKER_AGENT_FILE, "host decoy: agent must not overwrite this\n"], + ]); + for (const [name, content] of decoyContents) { + await writeFile(path.join(repo.path, name), content); + } + const snapshots = new Map(); + for (const name of decoyContents.keys()) { + const filePath = path.join(repo.path, name); + const metadata = await stat(filePath); + snapshots.set(name, { content: await readFile(filePath, "utf8"), mtimeMs: metadata.mtimeMs }); + } + const project = await seedRuntimeProject(repo); + return { + ...project, + hostMutationPath: path.join(repo.path, "host-mutation.txt"), + mutateHostAfterDockerCreation: async () => { + await writeFile( + path.join(repo.path, "host-mutation.txt"), + "host changed after Docker creation\n", + ); + }, + expectHostDecoysUntouched: async () => { + for (const [name, snapshot] of snapshots) { + const filePath = path.join(repo.path, name); + const metadata = await stat(filePath); + expect(await readFile(filePath, "utf8"), `${name} host decoy content`).toBe( + snapshot.content, + ); + expect(metadata.mtimeMs, `${name} host decoy mtime`).toBe(snapshot.mtimeMs); + } + }, + }; +} + async function readProbeRecords(projectId: string): Promise> { const paseoHome = process.env.E2E_PASEO_HOME; if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); @@ -116,6 +197,38 @@ export async function expectProviderAvailable(page: Page, providerLabel: string) await page.keyboard.press("Escape"); } +export async function selectRuntimeProviderModel( + page: Page, + input: { provider: string; model: string }, +): Promise { + const trigger = page.getByRole("button", { name: /Select model/ }); + await expect(trigger).toBeEnabled({ timeout: 30_000 }); + await trigger.click(); + const dialog = page.getByRole("dialog").last(); + await dialog.getByRole("button", { name: "Back", exact: true }).click(); + await dialog.getByText(input.provider, { exact: true }).click(); + const model = dialog.getByText(input.model, { exact: true }); + await expect(model).toBeVisible({ timeout: 30_000 }); + await model.click(); + await expect(trigger).toContainText(input.model); +} + +export async function expectRuntimeProviderUnavailable( + page: Page, + providerLabel: string, +): Promise { + const trigger = page.getByRole("button", { name: /Select model/ }); + await expect(trigger).toBeEnabled({ timeout: 30_000 }); + await trigger.click(); + const dialog = page.getByRole("dialog").last(); + await dialog.getByRole("button", { name: "Back", exact: true }).click(); + const providerId = providerLabel.toLowerCase().replaceAll(" ", ""); + const providerRow = dialog.getByTestId(`model-provider-${providerId}`); + await expect(providerRow).toBeVisible({ timeout: 30_000 }); + await expect(providerRow).toHaveText(`${providerLabel}Error`); + await page.keyboard.press("Escape"); +} + export async function expectFixtureProviderUnavailable(page: Page): Promise { const providerLabel = "Fixture Agent"; const trigger = page.getByRole("button", { name: /Select model/ }); @@ -239,7 +352,7 @@ export async function expectWorkspaceOpenInRuntime( page: Page, project: SeededRuntimeProject, runtimeId: string, -): Promise { +): Promise { const client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); try { const workspace = await assertNewWorkspaceSidebarAndHeader(page, { @@ -247,18 +360,348 @@ export async function expectWorkspaceOpenInRuntime( client, previousWorkspaceId: "", projectDisplayName: project.projectDisplayName, + timeoutMs: 120_000, }); const paseoHome = process.env.E2E_PASEO_HOME; if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); const records = JSON.parse( await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"), ) as Array<{ workspaceId: string; runtime?: { runtimeId: string } }>; - expect(records.find((record) => record.workspaceId === workspace.workspaceId)?.runtime).toEqual( - { - runtimeId, - }, - ); + const record = records.find((candidate) => candidate.workspaceId === workspace.workspaceId); + expect(record?.runtime).toEqual({ runtimeId }); + return workspace.workspaceId; } finally { await client.close(); } } + +export async function expectSelectedHostRuntimePlacement( + project: SeededRuntimeProject, + runtimeId: "local" | "worktree", + setupRan: boolean, +): Promise { + const paseoHome = process.env.E2E_PASEO_HOME; + if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); + const records = JSON.parse( + await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ + projectId: string; + cwd: string; + hostVisiblePath?: string | null; + runtime?: { runtimeId: string }; + }>; + const workspace = records.find( + (record) => record.projectId === project.projectId && record.runtime?.runtimeId === runtimeId, + ); + expect(workspace, `${runtimeId} selected workspace record`).toBeDefined(); + expect(workspace?.hostVisiblePath).toBe(workspace?.cwd); + if (setupRan) { + await expect.poll(() => markerExists(workspace!.cwd), { timeout: 30_000 }).toBe(true); + } else { + expect(await markerExists(workspace!.cwd)).toBe(false); + } +} + +export async function expectHostWorkspaceAffordances(page: Page): Promise { + await expect(page.getByRole("button", { name: "Open workspace in VS Code" })).toBeVisible(); + await page.getByRole("button", { name: "Choose editor" }).click(); + await expect(page.getByText("VS Code", { exact: true })).toBeVisible(); + await expect(page.getByText("Finder", { exact: true })).toBeVisible(); + await page.keyboard.press("Escape"); +} + +export async function expectDockerHasNoHostAffordance(page: Page): Promise { + await expect(page.getByRole("button", { name: /Open workspace in/ })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Choose editor" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /Open in file manager/ })).toHaveCount(0); +} + +export async function expectDockerWorkspaceHasNoHostVisiblePath( + project: SeededDockerRuntimeProject, +): Promise { + const paseoHome = process.env.E2E_PASEO_HOME; + if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); + const records = JSON.parse( + await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"), + ) as Array<{ + projectId: string; + runtime?: { runtimeId: string }; + hostVisiblePath?: string; + }>; + const workspace = records.find( + (record) => record.projectId === project.projectId && record.runtime?.runtimeId === "docker", + ); + expect(workspace, "Docker user workspace record").toBeDefined(); + expect(workspace?.hostVisiblePath).toBeNull(); +} + +export async function expectDockerTerminalText(page: Page, text: string): Promise { + await expect.poll(() => getTerminalBufferText(page), { timeout: 30_000 }).toContain(text); +} + +export async function expectDockerScriptTerminalOpen( + page: Page, + scriptName: string, +): Promise { + const scriptTab = page + .locator('[data-testid^="workspace-tab-terminal_"]') + .filter({ hasText: scriptName }); + await expect(scriptTab).toBeVisible({ timeout: 30_000 }); + await scriptTab.click(); +} + +export async function openDockerUncommittedChanges(page: Page): Promise { + await page.getByTestId("explorer-tab-changes").click(); + await page.getByRole("button", { name: "Diff mode", exact: true }).click(); + await page.getByTestId("changes-diff-mode-uncommitted").click(); + await expect(page.getByRole("button", { name: "Diff mode", exact: true })).toHaveText( + "Uncommitted", + ); +} + +export async function commitDockerWorkspaceChanges(page: Page): Promise { + await clickFirstTerminalTab(page); + const terminal = page.locator('[data-testid="terminal-surface"]').first(); + await terminal.click(); + await terminal.pressSequentially( + "git add committed.txt made-in-container.txt && git -c user.name='Paseo E2E' -c user.email='paseo@example.test' commit -m 'Docker UI acceptance commit'\n", + { delay: 0 }, + ); + await expectDockerTerminalText(page, "Docker UI acceptance commit"); +} + +export async function cleanupDockerRuntimeProject( + project: SeededDockerRuntimeProject, +): Promise { + const paseoHome = process.env.E2E_PASEO_HOME; + if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set"); + const recordPaths = [ + path.join(paseoHome, "projects", "provider-probes.json"), + path.join(paseoHome, "projects", "workspaces.json"), + ]; + const workspaceIds: string[] = []; + const userWorkspaceIds: string[] = []; + for (const recordPath of recordPaths) { + try { + await access(recordPath); + } catch { + continue; + } + const records = JSON.parse(await readFile(recordPath, "utf8")) as Array<{ + projectId: string; + workspaceId: string; + runtimeId?: string; + runtime?: { runtimeId: string }; + }>; + const matchingWorkspaceIds = records + .filter( + (record) => + record.projectId === project.projectId && + (record.runtimeId === "docker" || record.runtime?.runtimeId === "docker"), + ) + .map((record) => record.workspaceId); + workspaceIds.push(...matchingWorkspaceIds); + if (recordPath.endsWith("workspaces.json")) userWorkspaceIds.push(...matchingWorkspaceIds); + } + const names = workspaceIds.map((workspaceId) => dockerResourceName(workspaceId)); + const userNames = userWorkspaceIds.map((workspaceId) => dockerResourceName(workspaceId)); + const runtimeOwner = createHash("sha256").update(path.resolve(paseoHome)).digest("hex"); + let productionCleanupComplete = false; + try { + const cleanupClient = await connectSeedClient(); + try { + const agents = await cleanupClient.fetchAgents({ scope: "active" }); + const ownedAgents = agents.entries.filter((entry) => + userWorkspaceIds.includes(entry.agent.workspaceId ?? ""), + ); + await Promise.all(ownedAgents.map((entry) => cleanupClient.cancelAgent(entry.agent.id))); + await Promise.all(ownedAgents.map((entry) => cleanupClient.archiveAgent(entry.agent.id))); + for (const workspaceId of userWorkspaceIds) { + const scripts = await cleanupClient.listWorkspaceScripts(workspaceId); + await Promise.all( + scripts.scripts + .filter((script) => script.lifecycle === "running") + .map((script) => cleanupClient.stopWorkspaceScript(workspaceId, script.scriptName)), + ); + const listed = await cleanupClient.listTerminals(undefined, undefined, { workspaceId }); + await Promise.all( + listed.terminals.map((terminal) => cleanupClient.killTerminal(terminal.id)), + ); + await expect + .poll( + async () => + (await cleanupClient.listTerminals(undefined, undefined, { workspaceId })).terminals + .length, + { timeout: 15_000 }, + ) + .toBe(0); + } + } finally { + await cleanupClient.close(); + } + await cleanupProjectThroughProductionLifecycle(project, userWorkspaceIds, userNames); + productionCleanupComplete = true; + await expectDockerRuntimeGone(userWorkspaceIds, userNames); + } finally { + await cleanupValidatedDockerResources(workspaceIds, runtimeOwner); + await expectDockerRuntimeGone(workspaceIds, names); + expect(productionCleanupComplete).toBe(true); + } +} + +async function cleanupProjectThroughProductionLifecycle( + project: SeededDockerRuntimeProject, + workspaceIds: readonly string[], + names: readonly string[], +): Promise { + try { + await project.cleanup(); + return; + } catch (firstError) { + const lingeringProcesses = (await dockerRuntimeLeaks(workspaceIds, names)).processes; + if (lingeringProcesses.length > 0) { + await run("kill", ["-KILL", ...lingeringProcesses.map(String)]); + await expect + .poll(async () => (await dockerRuntimeLeaks(workspaceIds, names)).processes.length, { + timeout: 15_000, + }) + .toBe(0); + } + try { + await project.cleanup(); + } catch (retryError) { + throw new Error( + `Docker project cleanup failed through the production lifecycle after ${String(firstError)}`, + { cause: retryError }, + ); + } + } +} + +async function expectDockerRuntimeGone( + workspaceIds: readonly string[], + names: readonly string[], +): Promise { + await expect + .poll(() => dockerRuntimeLeaks(workspaceIds, names), { timeout: 15_000 }) + .toEqual({ containers: [], processes: [], volumes: [] }); +} + +async function cleanupValidatedDockerResources( + workspaceIds: readonly string[], + runtimeOwner: string, +): Promise { + for (const workspaceId of workspaceIds) { + const name = dockerResourceName(workspaceId); + const [container, volume] = await Promise.all([ + inspectDockerTestResource("container", name), + inspectDockerTestResource("volume", name), + ]); + assertDockerTestResourceOwner(container, { name, runtimeOwner, workspaceId }); + assertDockerTestResourceOwner(volume, { name, runtimeOwner, workspaceId }); + if (container) await removeDockerTestResource("container", name); + if (volume) await removeDockerTestResource("volume", name); + } +} + +interface DockerTestResourceInspection { + labels: Record; + name: string; +} + +async function inspectDockerTestResource( + kind: "container" | "volume", + name: string, +): Promise { + try { + const { stdout } = await run("docker", [kind, "inspect", name]); + const [inspection] = JSON.parse(stdout) as Array<{ + Config?: { Labels?: Record | null }; + Labels?: Record | null; + Name?: string; + }>; + return { + labels: (kind === "container" ? inspection?.Config?.Labels : inspection?.Labels) ?? {}, + name: inspection?.Name ?? name, + }; + } catch (error) { + if (await dockerTestResourceMissing(kind, name)) return null; + throw error; + } +} + +function assertDockerTestResourceOwner( + resource: DockerTestResourceInspection | null, + expected: { name: string; runtimeOwner: string; workspaceId: string }, +): void { + if (!resource) return; + expect(resource.labels).toMatchObject({ + [DOCKER_RUNTIME_LABELS.runtime]: "workspace", + [DOCKER_RUNTIME_LABELS.runtimeOwner]: expected.runtimeOwner, + [DOCKER_RUNTIME_LABELS.workspaceId]: expected.workspaceId, + }); +} + +async function removeDockerTestResource(kind: "container" | "volume", name: string): Promise { + try { + await run("docker", kind === "container" ? ["rm", "-f", name] : ["volume", "rm", "-f", name]); + } catch (error) { + if (await dockerTestResourceMissing(kind, name)) return; + throw error; + } +} + +async function dockerTestResourceMissing( + kind: "container" | "volume", + name: string, +): Promise { + try { + await run("docker", [kind, "inspect", name]); + return false; + } catch { + return true; + } +} + +function dockerResourceName(workspaceId: string): string { + const key = createHash("sha256").update(workspaceId).digest("hex").slice(0, 20); + return `paseo-ws-${key}`; +} + +async function dockerRuntimeLeaks( + workspaceIds: readonly string[], + names: readonly string[], +): Promise<{ containers: string[]; processes: number[]; volumes: string[] }> { + const containers = await existingDockerResources("container", names); + const volumes = await existingDockerResources("volume", names); + const { stdout } = await run("ps", ["-axo", "pid=,command="]); + const needles = workspaceIds.flatMap((workspaceId) => [ + dockerResourceName(workspaceId), + `--workspace-id ${workspaceId}`, + ]); + const processes = stdout + .split("\n") + .map((line) => line.match(/^\s*(\d+)\s+(.*)$/u)) + .filter((match): match is RegExpMatchArray => + Boolean(match && needles.some((needle) => match[2]?.includes(needle))), + ) + .map((match) => Number(match[1])); + return { containers, processes, volumes }; +} + +async function existingDockerResources( + kind: "container" | "volume", + names: readonly string[], +): Promise { + const checks = await Promise.all( + names.map(async (name) => { + try { + await run("docker", [kind, "inspect", name]); + return name; + } catch { + return null; + } + }), + ); + return checks.filter((name): name is string => name !== null); +} diff --git a/packages/app/e2e/support/helpers/new-workspace.ts b/packages/app/e2e/support/helpers/new-workspace.ts index 2c758c8df2..512b519355 100644 --- a/packages/app/e2e/support/helpers/new-workspace.ts +++ b/packages/app/e2e/support/helpers/new-workspace.ts @@ -501,6 +501,7 @@ export async function assertNewWorkspaceSidebarAndHeader( projectDisplayName: string; assertSidebarRow?: boolean; assertHeader?: boolean; + timeoutMs?: number; }, ): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> { // URL is the source of truth so concurrent sidebar rows cannot satisfy this. @@ -510,7 +511,7 @@ export async function assertNewWorkspaceSidebarAndHeader( const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId); return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null; }, - { timeout: 60_000 }, + { timeout: input.timeoutMs ?? 60_000 }, ) .not.toBeNull(); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 71a27dde5b..00e660c4a3 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -142,6 +142,7 @@ export interface SeedDaemonClient { timeout?: number, ): Promise<{ status: string; final?: { lastError?: string | null } | null }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; + cancelAgent(agentId: string): Promise; refreshAgent(agentId: string): Promise; fetchAgent(options: { agentId: string; @@ -172,6 +173,10 @@ export interface SeedDaemonClient { handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void, ): () => void; killTerminal(terminalId: string): Promise<{ error: string | null }>; + listWorkspaceScripts(workspaceId: string): Promise<{ + scripts: Array<{ lifecycle: "running" | "stopped"; scriptName: string }>; + }>; + stopWorkspaceScript(workspaceId: string, scriptName: string): Promise<{ error?: string | null }>; } export async function connectSeedClient(options?: { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 43ef54b3b2..0efb6b4862 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2799,6 +2799,62 @@ test("lists workspace runtimes and sends an explicit creation runtime", async () await expect(createPromise).resolves.toMatchObject({ error: "fixture sentinel" }); }); +test("keeps a selected-runtime workspace create request alive while runtime setup completes", async () => { + useHeartbeatClock(); + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_runtime_setup_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createWorkspace( + { + runtimeId: "docker", + source: { + kind: "directory", + path: "/tmp/project", + projectId: "local:/tmp/project", + }, + }, + "req-runtime-setup", + ); + let settled = false; + void createPromise.then( + () => { + settled = true; + return undefined; + }, + () => { + settled = true; + return undefined; + }, + ); + + await vi.advanceTimersByTimeAsync(65_000); + expect(settled).toBe(false); + + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.create.response", + payload: { + requestId: "req-runtime-setup", + workspace: null, + error: "late setup sentinel", + setupTerminalId: null, + }, + }), + ); + + await expect(createPromise).resolves.toMatchObject({ error: "late setup sentinel" }); +}); + test("sends project.remove.request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1dd3db5619..72bcdf0f8e 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -966,6 +966,7 @@ function toTimeoutError(error: unknown, label: string, timeoutMs: number): Error const DEFAULT_RECONNECT_BASE_DELAY_MS = 1500; const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000; const DEFAULT_SESSION_RPC_TIMEOUT_MS = 60_000; +const WORKSPACE_CREATE_RPC_TIMEOUT_MS = 10 * 60_000; const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; const DEFAULT_LIVENESS_TIMEOUT_MS = 5000; const LIVENESS_HEARTBEAT_INTERVAL_MS = 10_000; @@ -4419,6 +4420,7 @@ export class DaemonClient { : {}), }, responseType: "workspace.create.response", + ...(input.runtimeId !== undefined ? { timeout: WORKSPACE_CREATE_RPC_TIMEOUT_MS } : {}), }); } diff --git a/packages/desktop/e2e/new-workspace-docker.spec.ts b/packages/desktop/e2e/new-workspace-docker.spec.ts new file mode 100644 index 0000000000..f5cc89c59b --- /dev/null +++ b/packages/desktop/e2e/new-workspace-docker.spec.ts @@ -0,0 +1,162 @@ +import { test as base } from "../../app/e2e/support/fixtures"; +import { submitMessage } from "../../app/e2e/support/helpers/composer"; +import { + expectExplorerEntryVisible, + openFileExplorer, +} from "../../app/e2e/support/helpers/file-explorer"; +import { clickNewChat, clickNewTerminal } from "../../app/e2e/support/helpers/launcher"; +import { + cleanupDockerRuntimeProject, + commitDockerWorkspaceChanges, + createWorkspaceInSelectedRuntime, + expectDockerHasNoHostAffordance, + expectDockerScriptTerminalOpen, + expectDockerTerminalText, + expectDockerWorkspaceHasNoHostVisiblePath, + expectRuntimeProviderUnavailable, + expectWorkspaceOpenInRuntime, + gotoNewWorkspaceForRuntime, + openDockerUncommittedChanges, + seedGitProjectWithDockerDecoys, + selectRuntime, + selectRuntimeProviderModel, + type SeededDockerRuntimeProject, +} from "../../app/e2e/support/helpers/new-workspace-runtime"; +import { + focusTerminalSurface, + getTerminalBufferText, + typeInTerminal, + expectTerminalSurfaceVisible, + waitForTerminalAttached, +} from "../../app/e2e/support/helpers/terminal-perf"; +import { + closeWorkspaceScriptsMenu, + openWorkspaceScriptsMenu, + startWorkspaceScriptFromMenu, +} from "../../app/e2e/support/helpers/workspace-setup"; +import { getServerId } from "../../app/e2e/support/helpers/server-id"; +import { installDesktopRuntime } from "./support/runtime"; +import { expect } from "@playwright/test"; + +const test = base.extend<{ dockerProject: SeededDockerRuntimeProject }>({ + dockerProject: async ({ browserName: _browserName }, provide) => { + const project = await seedGitProjectWithDockerDecoys(); + try { + await provide(project); + } finally { + await cleanupDockerRuntimeProject(project); + } + }, +}); + +const hostOpenTargets = [ + { + id: "vscode", + label: "VS Code", + kind: "editor" as const, + icon: { kind: "symbol" as const, name: "terminal" as const }, + }, + { + id: "finder", + label: "Finder", + kind: "file-manager" as const, + icon: { kind: "symbol" as const, name: "folder" as const }, + }, +]; + +test.describe.configure({ timeout: 240_000 }); + +test("Docker workspace: probe, create, and work inside the container", async ({ + page, + dockerProject, +}) => { + await test.step("choose Docker and observe its provider catalog", async () => { + await installDesktopRuntime(page, { + serverId: getServerId(), + manageBuiltInDaemon: false, + editorTargets: hostOpenTargets, + }); + await gotoNewWorkspaceForRuntime(page, dockerProject); + await selectRuntime(page, "Docker"); + await expectRuntimeProviderUnavailable(page, "OpenCode"); + await selectRuntimeProviderModel(page, { + provider: "Docker Fixture", + model: "Fixture Model docker-fixture-model", + }); + }); + + await test.step("create the user workspace without host authority", async () => { + await createWorkspaceInSelectedRuntime(page); + await expectWorkspaceOpenInRuntime(page, dockerProject, "docker"); + await expectDockerHasNoHostAffordance(page); + await expectDockerWorkspaceHasNoHostVisiblePath(dockerProject); + await dockerProject.mutateHostAfterDockerCreation(); + }); + + await test.step("create and edit files from a terminal rooted at /workspace", async () => { + await clickNewTerminal(page); + await expectTerminalSurfaceVisible(page); + await waitForTerminalAttached(page); + await focusTerminalSurface(page); + await typeInTerminal( + page, + "pwd; cat host-mutation.txt; printf 'created in Docker\\n' > made-in-container.txt; printf 'changed in Docker\\n' > committed.txt\n", + ); + await expectDockerTerminalText(page, "/workspace"); + await expectDockerTerminalText(page, "committed before Docker creation"); + expect(await getTerminalBufferText(page)).not.toContain("host changed after Docker creation"); + }); + + await test.step("reflect terminal files and Git diff live", async () => { + await openFileExplorer(page); + await expectExplorerEntryVisible(page, "made-in-container.txt"); + await openDockerUncommittedChanges(page); + await expect(page.getByText("made-in-container.txt", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByText("committed.txt", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("host-mutation.txt", { exact: true })).toHaveCount(0); + await page.getByText("committed.txt", { exact: true }).first().click(); + await expect(page.getByText("changed in Docker", { exact: true })).toBeVisible(); + await commitDockerWorkspaceChanges(page); + await page.getByRole("button", { name: "Close explorer" }).first().click(); + }); + + await test.step("run the project workspace script inside Docker", async () => { + await openWorkspaceScriptsMenu(page); + await startWorkspaceScriptFromMenu(page, "docker-proof"); + await closeWorkspaceScriptsMenu(page); + await expectDockerScriptTerminalOpen(page, "docker-proof"); + await openFileExplorer(page); + await openDockerUncommittedChanges(page); + await expect( + page.getByText("workspace-script-output.txt", { exact: true }).first(), + ).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole("button", { name: "Close explorer" }).first().click(); + }); + + await test.step("launch the selected fixture agent and show its edit in Git", async () => { + await clickNewChat(page); + await selectRuntimeProviderModel(page, { + provider: "Docker Fixture", + model: "Fixture Model docker-fixture-model", + }); + await submitMessage(page, "agent-owned change"); + await expect( + page.getByText("fixture completed: agent-owned change", { exact: true }), + ).toBeVisible({ + timeout: 30_000, + }); + await page.getByRole("button", { name: "Open explorer" }).first().click(); + await openDockerUncommittedChanges(page); + await expect(page.getByText("stdio-agent-output.txt", { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + }); + + await test.step("leave every host decoy untouched", async () => { + await dockerProject.expectHostDecoysUntouched(); + }); +}); diff --git a/packages/desktop/e2e/new-workspace-runtime.spec.ts b/packages/desktop/e2e/new-workspace-runtime.spec.ts index 0934da308e..d8c1388ba1 100644 --- a/packages/desktop/e2e/new-workspace-runtime.spec.ts +++ b/packages/desktop/e2e/new-workspace-runtime.spec.ts @@ -6,8 +6,10 @@ import { expectProbeSkippedProjectSetup, expectProviderAvailable, expectFixtureProviderUnavailable, + expectHostWorkspaceAffordances, expectRuntimeChoices, expectRuntimeSelected, + expectSelectedHostRuntimePlacement, expectUserWorkspaceRanProjectSetup, expectWorkspaceOpenInRuntime, gotoNewWorkspaceForRuntime, @@ -24,6 +26,21 @@ import { } from "../../app/e2e/support/helpers/new-workspace"; import { installDesktopRuntime } from "./support/runtime"; +const hostOpenTargets = [ + { + id: "vscode", + label: "VS Code", + kind: "editor" as const, + icon: { kind: "symbol" as const, name: "terminal" as const }, + }, + { + id: "finder", + label: "Finder", + kind: "file-manager" as const, + icon: { kind: "symbol" as const, name: "folder" as const }, + }, +]; + const test = base.extend<{ runtimeProject: SeededRuntimeProject; nonGitRuntimeProject: SeededRuntimeProject; @@ -48,7 +65,11 @@ const test = base.extend<{ test("probes and creates a workspace in a selected runtime", async ({ page, runtimeProject }) => { await test.step("choose the project and runtime", async () => { - await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false }); + await installDesktopRuntime(page, { + serverId: getServerId(), + manageBuiltInDaemon: false, + editorTargets: hostOpenTargets, + }); await gotoNewWorkspaceForRuntime(page, runtimeProject); await expectRuntimeChoices(page, ["Local", "Worktree", "Docker", "Fixture", "Fixture Failure"]); await selectRuntime(page, "Fixture"); @@ -94,6 +115,35 @@ test("shows probe failure with retry without host providers", async ({ page, run }); }); +test("creates Local and Worktree through explicit runtime selection", async ({ + page, + runtimeProject, +}) => { + await test.step("create the selected Local workspace without project setup", async () => { + await installDesktopRuntime(page, { + serverId: getServerId(), + manageBuiltInDaemon: false, + editorTargets: hostOpenTargets, + }); + await gotoNewWorkspaceForRuntime(page, runtimeProject); + await selectRuntime(page, "Local"); + await createWorkspaceInSelectedRuntime(page); + await expectWorkspaceOpenInRuntime(page, runtimeProject, "local"); + await expectSelectedHostRuntimePlacement(runtimeProject, "local", false); + await expectHostWorkspaceAffordances(page); + }); + + await test.step("create the selected Worktree workspace with project setup", async () => { + await openGlobalNewWorkspaceComposer(page); + await selectNewWorkspaceProject(page, runtimeProject); + await selectRuntime(page, "Worktree"); + await createWorkspaceInSelectedRuntime(page); + await expectWorkspaceOpenInRuntime(page, runtimeProject, "worktree"); + await expectSelectedHostRuntimePlacement(runtimeProject, "worktree", true); + await expectHostWorkspaceAffordances(page); + }); +}); + test("hides Git runtimes for a non-Git project", async ({ page, nonGitRuntimeProject }) => { await installDesktopRuntime(page, { serverId: getServerId(), manageBuiltInDaemon: false }); await gotoNewWorkspaceForRuntime(page, nonGitRuntimeProject); diff --git a/packages/desktop/playwright.config.ts b/packages/desktop/playwright.config.ts index 3e74d1a5d0..4d0561edd5 100644 --- a/packages/desktop/playwright.config.ts +++ b/packages/desktop/playwright.config.ts @@ -2,10 +2,12 @@ import { defineConfig, devices } from "@playwright/test"; const baseURL = process.env.E2E_BASE_URL ?? `http://localhost:${process.env.E2E_METRO_PORT ?? "8081"}`; +const dockerAcceptance = process.env.E2E_DOCKER_ACCEPTANCE === "1"; export default defineConfig({ testDir: "./e2e", testMatch: ["**/*.spec.ts"], + testIgnore: dockerAcceptance ? [] : ["**/new-workspace-docker.spec.ts"], globalSetup: "../app/e2e/support/global-setup.ts", timeout: 60_000, expect: { diff --git a/packages/docker-workspace-runtime/Dockerfile b/packages/docker-workspace-runtime/Dockerfile index 02896deb7e..3a4f747d79 100644 --- a/packages/docker-workspace-runtime/Dockerfile +++ b/packages/docker-workspace-runtime/Dockerfile @@ -1,12 +1,20 @@ FROM node:22-bookworm-slim +ARG CODEX_VERSION=0.144.0 + RUN apt-get update \ - && apt-get install -y --no-install-recommends git procps \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y --no-install-recommends ca-certificates git procps zsh \ + && rm -rf /var/lib/apt/lists/* \ + && npm install --global "@openai/codex@${CODEX_VERSION}" \ + && npm cache clean --force \ + && mkdir -p /root/.codex + +ENV CODEX_HOME=/root/.codex COPY packages/docker-workspace-runtime/src/workload.mjs /opt/paseo-workspace-runtime/workload.mjs COPY packages/server/src/server/workspace-helper/executable.mjs /opt/paseo-workspace-helper/executable.mjs COPY packages/server/src/server/workspace-helper/executable.mjs /usr/bin/paseo-workspace-helper -RUN chmod 755 /usr/bin/paseo-workspace-helper +COPY packages/server/src/server/test-utils/fixtures/workspace-runtime-acp-agent.mjs /usr/local/bin/paseo-fixture-agent.mjs +RUN chmod 755 /usr/bin/paseo-workspace-helper /usr/local/bin/paseo-fixture-agent.mjs WORKDIR /workspace diff --git a/packages/docker-workspace-runtime/src/index.ts b/packages/docker-workspace-runtime/src/index.ts index 625dbc1485..9e0bfbb821 100644 --- a/packages/docker-workspace-runtime/src/index.ts +++ b/packages/docker-workspace-runtime/src/index.ts @@ -33,6 +33,11 @@ type PrivateRuntimeState = RuntimeState & { type LifecycleRequest = CommandRuntimeLifecycleRequest; type ExecRequest = CommandRuntimeSpawnEnvelope; type PtyControl = CommandRuntimeControl; +interface DockerBindMount { + source: string; + target: string; + readOnly: boolean; +} const PTY_CLEANUP_TIMEOUT_MS = 1_000; const EXEC_READY_TIMEOUT_MS = 5_000; @@ -49,7 +54,7 @@ try { }); } else if (operation === "reconcile") { const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin())); - await reconcile(request.workspaceIds ?? []); + await reconcile(request.workspaceIds ?? [], requireOwner(request.options)); writeJson(CommandRuntimeLifecycleResponseSchema, { protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "ok", @@ -59,11 +64,12 @@ try { } else if (operation === "exec") { await execute(requestedWorkspaceId); } else if (operation === "signal") { - await readStdin(); + const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin())); await signalExec( requestedWorkspaceId, requireArgument("--exec-id"), requireSignal(requireArgument("--signal")), + requireOwner(request.options), ); } else { const request = CommandRuntimeLifecycleRequestSchema.parse(JSON.parse(await readStdin())); @@ -84,11 +90,11 @@ try { writeJson(CommandRuntimeLifecycleResponseSchema, { protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "inspection", - inspection: await inspect(requestedWorkspaceId), + inspection: await inspect(requestedWorkspaceId, requireOwner(request.options)), }); break; case "pause": - await pause(requestedWorkspaceId); + await pause(requestedWorkspaceId, requireOwner(request.options)); writeJson(CommandRuntimeLifecycleResponseSchema, { protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "ok", @@ -96,7 +102,7 @@ try { break; case "resume": { - const state = await resume(requestedWorkspaceId); + const state = await resume(requestedWorkspaceId, requireOwner(request.options)); writeJson(CommandRuntimeLifecycleResponseSchema, { protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "state", @@ -106,7 +112,7 @@ try { } break; case "destroy": - await destroy(requestedWorkspaceId); + await destroy(requestedWorkspaceId, requireOwner(request.options)); writeJson(CommandRuntimeLifecycleResponseSchema, { protocolVersion: COMMAND_RUNTIME_PROTOCOL_VERSION, type: "ok", @@ -125,26 +131,21 @@ async function create( workspaceId: string, request: LifecycleRequest, ): Promise<{ state: PrivateRuntimeState; materializedFreshContent: boolean }> { - const existing = await inspectPrivate(workspaceId); + const owner = requireOwner(request.options); + const existing = await inspectPrivate(workspaceId, owner); if (existing.status === "ready" || existing.status === "paused") { return { state: existing.state, materializedFreshContent: false }; } const input = request.input; if (!input) throw new Error("create input is required"); const image = requireImage(request.options); + const bindMounts = requireBindMounts(request.options); const relativeRoot = input.project.source.kind === "git" ? input.project.source.subdirectory : input.placement.relativeCwd; const requestedRoot = resolveWorkspaceCwd("/workspace", relativeRoot); const names = resourceNames(workspaceId); - const existingVolume = await inspectVolume(names.volume); - if (existingVolume) { - if (existingVolume.runtime !== "workspace" || existingVolume.owner !== workspaceId) { - throw new Error(`Docker volume ownership mismatch: ${names.volume}`); - } - throw new Error(`Docker workspace has an incomplete owned volume: ${workspaceId}`); - } await docker([ "volume", "create", @@ -152,8 +153,13 @@ async function create( "sh.paseo.runtime=workspace", "--label", `sh.paseo.workspace-id=${workspaceId}`, + "--label", + `sh.paseo.runtime-owner=${owner}`, names.volume, ]); + const createdVolume = await inspectVolume(names.volume); + if (!createdVolume) throw new Error(`Docker volume disappeared after creation: ${names.volume}`); + assertOwnedResource("volume", names.volume, createdVolume, workspaceId, owner); try { const source = input.project.source; @@ -214,16 +220,20 @@ async function create( "--label", `sh.paseo.workspace-id=${workspaceId}`, "--label", + `sh.paseo.runtime-owner=${owner}`, + "--label", `sh.paseo.workspace-root=${root}`, "--label", `sh.paseo.workspace-revision=${commit}`, "-v", `${names.volume}:/workspace`, + ...dockerBindMountArguments(bindMounts), image, "/bin/sh", "-c", "exec tail -f /dev/null", ]); + await assertOwnedWorkspaceResources(workspaceId, owner); await docker(["start", names.container]); await waitUntilReady(names.container); return { @@ -231,13 +241,49 @@ async function create( materializedFreshContent: true, }; } catch (error) { - await docker(["rm", "-f", names.container], true); - await docker(["volume", "rm", "-f", names.volume], true); + const cleanupErrors = await cleanupOwnedCreation(workspaceId, owner); + if (cleanupErrors.length > 0) { + throw new Error( + `Docker creation rollback failed: ${cleanupErrors.map((cleanupError) => cleanupError.message).join("; ")}`, + { cause: error }, + ); + } throw error; } } -async function inspectPrivate(workspaceId: string): Promise< +interface DockerResourceLabels { + runtime: string | null; + workspaceId: string | null; + runtimeOwner: string | null; +} + +interface DockerContainerInspection extends DockerResourceLabels { + running: boolean; + root: string | null; + revision: string | null; +} + +async function assertOwnedWorkspaceResources( + workspaceId: string, + expectedOwner: string, +): Promise { + const names = resourceNames(workspaceId); + const [container, volume] = await Promise.all([ + inspectContainer(names.container), + inspectVolume(names.volume), + ]); + if (!container || !volume) { + throw new Error(`Docker workspace resources are incomplete: ${workspaceId}`); + } + assertOwnedResource("container", names.container, container, workspaceId, expectedOwner); + assertOwnedResource("volume", names.volume, volume, workspaceId, expectedOwner); +} + +async function inspectPrivate( + workspaceId: string, + expectedOwner: string, +): Promise< | { status: "missing" } | { status: "ready" | "paused"; @@ -246,85 +292,106 @@ async function inspectPrivate(workspaceId: string): Promise< } > { const names = resourceNames(workspaceId); - const inspection = await docker( - [ - "inspect", - "--format", - '{{.State.Running}}|{{index .Config.Labels "sh.paseo.runtime"}}|{{index .Config.Labels "sh.paseo.workspace-id"}}|{{index .Config.Labels "sh.paseo.workspace-root"}}|{{index .Config.Labels "sh.paseo.workspace-revision"}}', - names.container, - ], - true, - ); - if (!inspection) return { status: "missing" }; - const [running, owner, inspectedWorkspaceId, root, revision] = inspection.trim().split("|"); - if (owner !== "workspace" || inspectedWorkspaceId !== workspaceId || !root || !revision) { - throw new Error(`Docker resource ownership mismatch: ${names.container}`); + const [container, volume] = await Promise.all([ + inspectContainer(names.container), + inspectVolume(names.volume), + ]); + if (container) + assertOwnedResource("container", names.container, container, workspaceId, expectedOwner); + if (volume) assertOwnedResource("volume", names.volume, volume, workspaceId, expectedOwner); + if (!container && !volume) return { status: "missing" }; + if (!container || !volume) { + throw new Error(`Docker workspace resources are incomplete: ${workspaceId}`); + } + if (!container.root || !container.revision) { + throw new Error(`Docker container placement is incomplete: ${names.container}`); } const state = stateFor( workspaceId, - root, - revision, + container.root, + container.revision, names.container, - running === "true" ? "ready" : "paused", + container.running ? "ready" : "paused", ); return { - status: running === "true" ? "ready" : "paused", + status: container.running ? "ready" : "paused", state, placement: runtimePlacement(state), }; } -async function inspect(workspaceId: string) { - const inspection = await inspectPrivate(workspaceId); +async function inspectContainer(name: string): Promise { + const output = await docker( + [ + "inspect", + "--format", + '{{json .State.Running}}|{{json (index .Config.Labels "sh.paseo.runtime")}}|{{json (index .Config.Labels "sh.paseo.workspace-id")}}|{{json (index .Config.Labels "sh.paseo.runtime-owner")}}|{{json (index .Config.Labels "sh.paseo.workspace-root")}}|{{json (index .Config.Labels "sh.paseo.workspace-revision")}}', + name, + ], + true, + ); + if (!output) return null; + const [running, runtime, workspaceId, runtimeOwner, root, revision] = output + .trim() + .split("|") + .map(parseDockerJsonValue); + return { + running: running === true, + runtime: typeof runtime === "string" ? runtime : null, + workspaceId: typeof workspaceId === "string" ? workspaceId : null, + runtimeOwner: typeof runtimeOwner === "string" ? runtimeOwner : null, + root: typeof root === "string" ? root : null, + revision: typeof revision === "string" ? revision : null, + }; +} + +async function inspect(workspaceId: string, expectedOwner: string) { + const inspection = await inspectPrivate(workspaceId, expectedOwner); if (inspection.status === "ready" || inspection.status === "paused") { return { ...inspection, state: publicState(inspection.state) }; } return inspection; } -async function pause(workspaceId: string): Promise { - const current = await inspectPrivate(workspaceId); +async function pause(workspaceId: string, expectedOwner: string): Promise { + const current = await inspectPrivate(workspaceId, expectedOwner); if (current.status === "missing" || current.status === "paused") return; await docker(["stop", "--time", "5", resourceNames(workspaceId).container]); } -async function resume(workspaceId: string): Promise { - const current = await inspectPrivate(workspaceId); +async function resume(workspaceId: string, expectedOwner: string): Promise { + const current = await inspectPrivate(workspaceId, expectedOwner); if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`); if (current.status === "paused") await docker(["start", resourceNames(workspaceId).container]); await waitUntilReady(resourceNames(workspaceId).container); - const ready = await inspectPrivate(workspaceId); + const ready = await inspectPrivate(workspaceId, expectedOwner); if (ready.status !== "ready") throw new Error(`Docker workspace did not resume: ${workspaceId}`); return ready.state; } -async function destroy(workspaceId: string): Promise { +async function destroy(workspaceId: string, expectedOwner: string): Promise { const names = resourceNames(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}`); - } - if (current.status !== "missing") await docker(["rm", "-f", names.container]); - if (volume) await docker(["volume", "rm", names.volume]); + const current = await inspectPrivate(workspaceId, expectedOwner); + if (current.status === "missing") return; + await docker(["rm", "-f", names.container]); + await docker(["volume", "rm", names.volume]); } -async function inspectVolume( - name: string, -): Promise<{ owner: string | null; runtime: string | null } | null> { +async function inspectVolume(name: string): Promise { const output = await docker(["volume", "inspect", name], true); if (!output) return null; const inspection = JSON.parse(output) as Array<{ Labels?: Record | null }>; return { - owner: inspection[0]?.Labels?.["sh.paseo.workspace-id"] ?? null, + workspaceId: inspection[0]?.Labels?.["sh.paseo.workspace-id"] ?? null, runtime: inspection[0]?.Labels?.["sh.paseo.runtime"] ?? null, + runtimeOwner: inspection[0]?.Labels?.["sh.paseo.runtime-owner"] ?? null, }; } -async function reconcile(workspaceIds: readonly string[]): Promise { +async function reconcile(workspaceIds: readonly string[], owner: string): Promise { const known = new Set(workspaceIds); - const containers = await labeledResources("ps"); - const volumes = await labeledResources("volume"); + const containers = await labeledResources("ps", owner); + const volumes = await labeledResources("volume", owner); const candidateWorkspaceIds = new Set([...containers.values(), ...volumes.values()]); for (const workspaceId of candidateWorkspaceIds) { if (!workspaceId || known.has(workspaceId)) continue; @@ -332,18 +399,19 @@ async function reconcile(workspaceIds: readonly string[]): Promise { const containerProven = containers.get(names.container) === workspaceId; const volumeProven = volumes.get(names.volume) === workspaceId; if (!containerProven && !volumeProven) continue; - if (containerProven) { - const inspection = await inspect(workspaceId); - if (inspection.status === "missing") continue; - } + const container = await inspectContainer(names.container); const volume = await inspectVolume(names.volume); - if (volume && (volume.runtime !== "workspace" || volume.owner !== workspaceId)) continue; - if (containerProven) await docker(["rm", "-f", names.container]); + if (container) assertOwnedResource("container", names.container, container, workspaceId, owner); + if (volume) assertOwnedResource("volume", names.volume, volume, workspaceId, owner); + if (containerProven && container) await docker(["rm", "-f", names.container]); if (volumeProven && volume) await docker(["volume", "rm", names.volume]); } } -async function labeledResources(kind: "ps" | "volume"): Promise> { +async function labeledResources( + kind: "ps" | "volume", + owner: string, +): Promise> { const output = kind === "ps" ? await docker([ @@ -351,6 +419,8 @@ async function labeledResources(kind: "ps" | "volume"): Promise { const request = CommandRuntimeControlSchema.parse(JSON.parse(first.value)); if (request.type !== "spawn") throw new Error("Runtime exec spawn control is required"); if (!/^[a-f0-9]{32}$/.test(request.execId)) throw new Error("Invalid exec id"); - const current = await inspectPrivate(workspaceId); + const expectedOwner = requireOwner(request.options); + const current = await inspectPrivate(workspaceId, expectedOwner); if (current.status !== "ready") throw new Error(`Docker workspace is ${current.status}`); const requestedCwd = resolveWorkspaceCwd(current.state.root, request.cwd); const cwd = ( @@ -449,7 +522,7 @@ async function execute(workspaceId: string): Promise { forwardedSignal = signal; try { await withTimeout( - signalExec(workspaceId, execId, signal), + signalExec(workspaceId, execId, signal, expectedOwner), PTY_CLEANUP_TIMEOUT_MS, "Docker signal helper timed out", ); @@ -457,7 +530,7 @@ async function execute(workspaceId: string): Promise { if (signal === "SIGKILL") throw error; try { await withTimeout( - signalExec(workspaceId, execId, "SIGKILL"), + signalExec(workspaceId, execId, "SIGKILL", expectedOwner), PTY_CLEANUP_TIMEOUT_MS, "Docker forced signal helper timed out", ); @@ -572,6 +645,7 @@ async function executeDockerPty(input: { input.request.execId, created.Id, input.controls, + requireOwner(input.request.options), (signal) => { requestedSignal = signal; }, @@ -591,7 +665,12 @@ async function executeDockerPty(input: { let cleanupError: Error | null = null; try { await withTimeout( - signalExec(input.workspaceId, input.request.execId, "SIGKILL"), + signalExec( + input.workspaceId, + input.request.execId, + "SIGKILL", + requireOwner(input.request.options), + ), PTY_CLEANUP_TIMEOUT_MS, "Docker PTY signal helper timed out", ); @@ -736,6 +815,7 @@ async function handlePtyControls( execId: string, dockerExecId: string, controls: AsyncIterator, + expectedOwner: string, onSignal: (signal: NodeJS.Signals) => void, onResize: (id: number) => void, ): Promise { @@ -749,7 +829,7 @@ async function handlePtyControls( } else if (control.type === "signal") { const signal = requireSignal(control.signal); onSignal(signal); - await signalExec(workspaceId, execId, signal); + await signalExec(workspaceId, execId, signal, expectedOwner); } else { throw new Error(`Unexpected PTY control: ${control.type}`); } @@ -829,9 +909,10 @@ async function signalExec( workspaceId: string, execId: string, signal: NodeJS.Signals, + expectedOwner: string, ): Promise { if (!/^[a-f0-9]{32}$/.test(execId)) throw new Error("Invalid exec id"); - const current = await inspectPrivate(workspaceId); + const current = await inspectPrivate(workspaceId, expectedOwner); if (current.status === "missing") throw new Error(`Docker workspace is missing: ${workspaceId}`); const files = executionFiles(execId); await docker([ @@ -882,6 +963,95 @@ function requireImage(options: LifecycleRequest["options"]): string { return options.image; } +function requireOwner(options: LifecycleRequest["options"]): string { + if (typeof options.owner !== "string" || !/^[a-f0-9]{64}$/.test(options.owner)) { + throw new Error("Docker runtime option owner must be a SHA-256 identity"); + } + return options.owner; +} + +function parseDockerJsonValue(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function assertOwnedResource( + kind: "container" | "volume", + name: string, + resource: DockerResourceLabels, + workspaceId: string, + expectedOwner: string, +): void { + if (resource.runtime !== "workspace" || resource.workspaceId !== workspaceId) { + throw new Error(`Docker ${kind} ownership mismatch: ${name}`); + } + if (resource.runtimeOwner !== expectedOwner) { + throw new Error(`Docker ${kind} runtime owner mismatch: ${name}`); + } +} + +async function cleanupOwnedCreation(workspaceId: string, expectedOwner: string): Promise { + const names = resourceNames(workspaceId); + const errors: Error[] = []; + const [container, volume] = await Promise.all([ + inspectContainer(names.container), + inspectVolume(names.volume), + ]); + const remove = async ( + kind: "container" | "volume", + resource: DockerResourceLabels | null, + args: string[], + ): Promise => { + if (!resource) return; + try { + assertOwnedResource(kind, names[kind], resource, workspaceId, expectedOwner); + await docker(args); + } catch (error) { + errors.push(error instanceof Error ? error : new Error(String(error))); + } + }; + await remove("container", container, ["rm", "-f", names.container]); + await remove("volume", volume, ["volume", "rm", "-f", names.volume]); + return errors; +} + +function requireBindMounts(options: LifecycleRequest["options"]): readonly DockerBindMount[] { + const value = options.bindMounts; + if (value === undefined) return []; + if (!Array.isArray(value)) throw new Error("Docker runtime option bindMounts must be an array"); + return value.map((entry, index) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`Docker bind mount ${index} must be an object`); + } + const { source, target, readOnly } = entry as Record; + if (typeof source !== "string" || !path.isAbsolute(source)) { + throw new Error(`Docker bind mount ${index} source must be absolute`); + } + if (typeof target !== "string" || !path.posix.isAbsolute(target)) { + throw new Error(`Docker bind mount ${index} target must be an absolute container path`); + } + const normalizedTarget = path.posix.normalize(target); + const workspaceRelative = path.posix.relative("/workspace", normalizedTarget); + if (workspaceRelative === "" || !workspaceRelative.startsWith("../")) { + throw new Error(`Docker bind mount ${index} target must be outside /workspace`); + } + if (typeof readOnly !== "boolean") { + throw new Error(`Docker bind mount ${index} readOnly must be boolean`); + } + return { source, target: normalizedTarget, readOnly }; + }); +} + +function dockerBindMountArguments(bindMounts: readonly DockerBindMount[]): string[] { + return bindMounts.flatMap(({ source, target, readOnly }) => [ + "--mount", + `type=bind,source=${source},target=${target}${readOnly ? ",readonly" : ""}`, + ]); +} + function resourceNames(workspaceId: string): { container: string; volume: string } { const key = createHash("sha256").update(workspaceId).digest("hex").slice(0, 20); return { container: `paseo-ws-${key}`, volume: `paseo-ws-${key}` }; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4a5bc73c29..8ab9c0b284 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -963,14 +963,6 @@ export async function createPaseoDaemon( runtimeId: string, placement: { cwd: string; hostVisiblePath?: string }, ): Promise { - const existing = await workspaceRegistry?.get(workspaceId); - if ( - existing?.runtime?.runtimeId === runtimeId && - existing.cwd === placement.cwd && - existing.hostVisiblePath === (placement.hostVisiblePath ?? null) - ) { - return; - } const updated = await workspaceRegistry?.update(workspaceId, (workspace) => ({ ...workspace, cwd: placement.cwd, diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index e73150fec2..3b7e177f18 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -179,6 +179,19 @@ describe("DaemonConfigStore", () => { JSON.stringify( { ...initial, + workspaceRuntimes: { + docker: { + type: "docker", + image: "paseo-workspace:test", + bindMounts: [ + { + source: "/host/agent/auth.json", + target: "/root/.agent/auth.json", + readOnly: true, + }, + ], + }, + }, agents: { providers: { gemini: { @@ -221,6 +234,17 @@ describe("DaemonConfigStore", () => { command: ["gemini", "--acp"], enabled: false, }); + expect(persisted.workspaceRuntimes?.docker).toEqual({ + type: "docker", + image: "paseo-workspace:test", + bindMounts: [ + { + source: "/host/agent/auth.json", + target: "/root/.agent/auth.json", + readOnly: true, + }, + ], + }); }); test("patch removes provider entries from config.json", () => { diff --git a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts index 1c47cab8b7..647e2eddad 100644 --- a/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/workspace-runtime-characterization.e2e.test.ts @@ -101,6 +101,12 @@ function createRepository(): string { `${JSON.stringify(process.execPath)} -e "require('fs').writeFileSync('setup-output.txt', 'setup complete\\n')"`, ], }, + scripts: { + characterize: { + command: + "sleep 1; printf workspace-script > workspace-script-output.txt; printf runtime-script-ok", + }, + }, }), ); execFileSync("git", ["add", "."], { cwd: repo, stdio: "pipe" }); @@ -114,6 +120,7 @@ function createRepository(): string { async function createCharacterizedWorkspace(kind: "local" | "worktree") { const repo = createRepository(); const result = await client.createWorkspace({ + runtimeId: kind, source: kind === "local" ? { kind: "directory", path: repo } @@ -130,6 +137,8 @@ async function createCharacterizedWorkspace(kind: "local" | "worktree") { if (!workspace?.workspaceDirectory) { throw new Error(result.error ?? `Failed to create ${kind} workspace`); } + const projection = await client.fetchWorkspaces(); + expect(projection.entries.map((candidate) => candidate.id)).toContain(workspace.id); return { cwd: workspace.workspaceDirectory, id: workspace.id, @@ -275,6 +284,30 @@ async function expectTerminalCommand(workspace: CharacterizedWorkspace): Promise } } +async function expectWorkspaceScript(workspace: CharacterizedWorkspace): Promise { + const listed = await client.listWorkspaceScripts(workspace.id); + expect(listed.scripts).toContainEqual( + expect.objectContaining({ scriptName: "characterize", lifecycle: "stopped" }), + ); + const started = await client.startWorkspaceScriptWithStatus(workspace.id, "characterize"); + expect(started.error).toBeNull(); + const terminalId = started.script?.terminalId; + if (!terminalId) throw new Error("Workspace script did not expose its terminal"); + await client.subscribeTerminal(terminalId); + await expect(waitForTerminalOutput(terminalId, "runtime-script-ok")).resolves.toContain( + "runtime-script-ok", + ); + await expect + .poll( + () => + readWorkspaceTextFile(workspace.id, workspace.cwd, "workspace-script-output.txt").catch( + () => "missing", + ), + { timeout: 15_000 }, + ) + .toBe("workspace-script"); +} + async function expectProviderDiscoveryAndAgentExecution( workspace: CharacterizedWorkspace, ): Promise { @@ -338,6 +371,7 @@ describe("current workspace runtime journeys", () => { await expectFileEditAndWatch(workspace); await expectGitObservation(workspace); await expectTerminalCommand(workspace); + await expectWorkspaceScript(workspace); await expectProviderDiscoveryAndAgentExecution(workspace); const archive = await client.archiveWorkspace(workspace.id); expect(archive.error).toBeNull(); @@ -371,6 +405,7 @@ describe("current workspace runtime journeys", () => { await expectFileEditAndWatch(workspace); await expectGitObservation(workspace); await expectTerminalCommand(workspace); + await expectWorkspaceScript(workspace); await expectProviderDiscoveryAndAgentExecution(workspace); let resolveRestoredObservation!: () => void; diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 2eca0eb155..d10970353f 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -198,6 +198,46 @@ describe("PersistedConfigSchema workspace runtime config", () => { }), ).toThrow("Workspace runtime id is reserved: docker"); }); + + test("accepts trusted Docker bind mounts and rejects unsafe workspace targets", () => { + expect( + PersistedConfigSchema.parse({ + workspaceRuntimes: { + docker: { + type: "docker", + bindMounts: [ + { + source: "/host/credentials.json", + target: "/root/.agent/credentials.json", + readOnly: true, + }, + ], + }, + }, + }).workspaceRuntimes?.docker, + ).toMatchObject({ + bindMounts: [ + { + source: "/host/credentials.json", + target: "/root/.agent/credentials.json", + readOnly: true, + }, + ], + }); + + for (const bindMounts of [ + [{ source: "relative", target: "/root/config", readOnly: true }], + [{ source: "/host/config", target: "relative", readOnly: true }], + [{ source: "/host/repo", target: "/workspace", readOnly: true }], + [{ source: "/host/repo", target: "/workspace/nested", readOnly: true }], + ]) { + expect(() => + PersistedConfigSchema.parse({ + workspaceRuntimes: { docker: { type: "docker", bindMounts } }, + }), + ).toThrow(); + } + }); }); describe("PersistedConfigSchema provider credentials", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index e33f6ab3b2..6f691634fd 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -100,10 +100,26 @@ const CommandWorkspaceRuntimeConfigSchema = z }) .strict(); +const DockerBindMountSchema = z + .object({ + source: z.string().min(1).refine(path.isAbsolute, "Bind mount source must be absolute"), + target: z + .string() + .min(1) + .refine(path.posix.isAbsolute, "Bind mount target must be an absolute container path") + .refine((target) => { + const relative = path.posix.relative("/workspace", path.posix.normalize(target)); + return relative === ".." || relative.startsWith("../"); + }, "Bind mount target must be outside /workspace"), + readOnly: z.boolean(), + }) + .strict(); + const DockerWorkspaceRuntimeConfigSchema = z .object({ type: z.literal("docker"), image: z.string().min(1).optional(), + bindMounts: z.array(DockerBindMountSchema).optional(), providerEnvironment: z.record(z.string(), z.string()).optional(), enabled: z.boolean().optional(), }) diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index a97e82f85d..6c3beb88be 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -506,6 +506,30 @@ test("directory creation persists the live branch and a trimmed title", async () expect(workspace).toMatchObject({ branch: "main", title: "Focused work" }); }); +test("selected-runtime directory creation stays provisional until runtime placement is persisted", async () => { + const repo = path.join(tmpDir, "repo"); + gitRoots.add(repo); + + const workspace = await provisioning.createWorkspaceForDirectory(repo, null, undefined, { + runtimeId: "docker", + }); + + expect(await workspaceRegistry.get(workspace.workspaceId)).toMatchObject({ + cwd: repo, + runtime: { runtimeId: "docker" }, + }); + expect(await workspaceRegistry.list()).toEqual([]); + + await workspaceRegistry.update(workspace.workspaceId, (record) => ({ + ...record, + cwd: "/workspace", + })); + + expect(await workspaceRegistry.list()).toEqual([ + expect.objectContaining({ workspaceId: workspace.workspaceId, cwd: "/workspace" }), + ]); +}); + test("createWorkspaceForDirectory honors an explicit active project without cwd containment", async () => { const project = await projectRegistry.getOrCreateActiveByRoot({ rootPath: path.join(tmpDir, "elsewhere"), diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index e70ab6e3f3..3ff8a8e7ad 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -225,7 +225,10 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, }); - await workspaceRegistry.upsert(workspace, context); + await workspaceRegistry.upsert(workspace, { + ...context, + provisional: context?.runtimeId !== undefined, + }); return workspace; } diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index 539048a94e..6474c2b727 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -220,7 +220,7 @@ describe("buildSnapshot", () => { await service.launch({ workspaceId: workspace.workspaceId, scriptName: "app" }); expect(spawnCalls[0]).toMatchObject({ workspaceId: workspace.workspaceId, - runtimeCwd: ".", + runtimeCwd: dir, paseoConfig: { scripts: { app: { command: "npm run app" } } }, }); expect(spawnCalls[0]?.runtime).toBeDefined(); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index 4d7e376552..a56303fd64 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -217,7 +217,7 @@ export function createWorkspaceScriptsService(deps: { repoRoot: workspace.cwd, ...(workspace.runtime ? { - runtimeCwd: ".", + runtimeCwd: workspace.cwd, paseoConfig: paseoConfig!, runtime: await workspaceRuntime!.bind(workspace.workspaceId), } diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 33621c9cfa..1848b42aca 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -436,7 +436,10 @@ describe("workspace registries", () => { await workspaceRegistry.upsert(reserved, { provisional: true }); - expect(await workspaceRegistry.get(reserved.workspaceId)).toEqual(reserved); + expect(await workspaceRegistry.get(reserved.workspaceId)).toEqual({ + ...reserved, + materializingAt: reserved.updatedAt, + }); expect(await workspaceRegistry.list()).toEqual([]); expect(mutations).toEqual([ expect.objectContaining({ workspaceId: reserved.workspaceId, provisional: true }), @@ -458,6 +461,34 @@ describe("workspace registries", () => { ); }); + test("keeps a runtime reservation hidden after restart until final placement is published", async () => { + await workspaceRegistry.initialize(); + const reserved = createPersistedWorkspaceRecord({ + workspaceId: "interrupted-runtime-workspace", + projectId: "runtime-project", + cwd: "/tmp/source-must-stay-private", + kind: "worktree", + displayName: "interrupted runtime", + runtime: { runtimeId: "docker" }, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + + await workspaceRegistry.upsert(reserved, { provisional: true }); + + const restarted = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await restarted.initialize(); + + expect(await restarted.get(reserved.workspaceId)).toMatchObject({ + workspaceId: reserved.workspaceId, + cwd: "/tmp/source-must-stay-private", + }); + expect(await restarted.list()).toEqual([]); + }); + test("keeps the committed workspace snapshot when a removal cannot be persisted", async () => { const registryPath = path.join(tmpDir, "projects", "workspaces.json"); const backupPath = `${registryPath}.backup`; diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index d8e6f8cc83..03c765b63d 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -83,6 +83,7 @@ const PersistedWorkspaceRecordSchema = z.object({ isPaseoOwnedWorktree: z.boolean().default(false), mainRepoRoot: z.string().nullable().default(null), runtime: z.object({ runtimeId: z.string().min(1) }).optional(), + materializingAt: z.string().optional(), // COMPAT(workspaceDeletionIntent): added in v0.2.7 on 2026-08-11; remove optional after 2027-02-11. deletionRequestedAt: z .string() @@ -484,7 +485,6 @@ export class FileBackedWorkspaceRegistry extends FileBackedRegistry implements WorkspaceRegistry { - private readonly provisionalWorkspaceIds = new Set(); private readonly mutationListeners = new Set< (mutation: WorkspaceMutation) => void | Promise >(); @@ -508,18 +508,19 @@ export class FileBackedWorkspaceRegistry override async list(): Promise { const workspaces = await super.list(); - return workspaces.filter( - (workspace) => !this.provisionalWorkspaceIds.has(workspace.workspaceId), - ); + return workspaces.filter((workspace) => workspace.materializingAt === undefined); } override async update( workspaceId: string, updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord, ): Promise { - const workspace = await super.update(workspaceId, updater); + const workspace = await super.update(workspaceId, (record) => { + const updated = updater(record); + const { materializingAt: _materializingAt, ...published } = updated; + return published; + }); if (workspace) { - this.provisionalWorkspaceIds.delete(workspaceId); await this.notifyMutation({ kind: "upsert", workspaceId, workspace }); } return workspace; @@ -529,16 +530,15 @@ export class FileBackedWorkspaceRegistry record: PersistedWorkspaceRecord, context?: WorkspaceMutationContext, ): Promise { - await super.upsert(record); - if (context?.provisional) { - this.provisionalWorkspaceIds.add(record.workspaceId); - } else { - this.provisionalWorkspaceIds.delete(record.workspaceId); - } + const { materializingAt: _materializingAt, ...withoutMaterializingState } = record; + const persisted = context?.provisional + ? { ...withoutMaterializingState, materializingAt: record.updatedAt } + : withoutMaterializingState; + await super.upsert(persisted); await this.notifyMutation({ kind: "upsert", workspaceId: record.workspaceId, - workspace: record, + workspace: persisted, ...(context?.expectsInitialAgent ? { expectsInitialAgent: true } : {}), ...(context?.provisional ? { provisional: true } : {}), }); @@ -549,7 +549,6 @@ export class FileBackedWorkspaceRegistry archivedAt: string, context?: WorkspaceArchiveContext, ): Promise { - this.provisionalWorkspaceIds.delete(workspaceId); const workspace = await super.update(workspaceId, (existing) => ({ ...existing, updatedAt: archivedAt, @@ -563,7 +562,6 @@ export class FileBackedWorkspaceRegistry } override async remove(workspaceId: string): Promise { - this.provisionalWorkspaceIds.delete(workspaceId); const workspace = await this.removeIfPresent(workspaceId); if (!workspace) return; await this.notifyMutation({ kind: "remove", workspaceId, workspace: null }); 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 49114ab9be..d8de96eeda 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 @@ -77,6 +77,7 @@ export function createCommandRuntime( requiresGitProject: true, reconciliationDomainId: JSON.stringify({ command: config.command, options: config.options }), workspaceHelperCommand: config.helperCommand ?? ["paseo-workspace-helper"], + scriptTerminal: { kind: "direct-command", command: "/bin/sh", argsPrefix: ["-lc"] }, async create(input) { const response = await lifecycle("create", input.workspaceId, input); if (response.type !== "state") throw new Error(`Invalid create response from ${runtimeId}`); diff --git a/packages/server/src/server/workspace-runtime/drivers/index.ts b/packages/server/src/server/workspace-runtime/drivers/index.ts index 75b58d0b1f..f797eb277b 100644 --- a/packages/server/src/server/workspace-runtime/drivers/index.ts +++ b/packages/server/src/server/workspace-runtime/drivers/index.ts @@ -103,6 +103,7 @@ export interface WorkspaceRuntimeDriver { readonly reconciliationDomainId?: string; /** Runtime-local command for Paseo's compatible workspace helper. */ readonly workspaceHelperCommand: readonly [string, ...string[]]; + readonly scriptTerminal: import("../index.js").WorkspaceScriptTerminal; create(input: WorkspaceDriverCreateInput): Promise; inspect(workspaceId: WorkspaceId): Promise; spawn(input: WorkspaceDriverSpawnInput): Promise; diff --git a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.test.ts b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.test.ts new file mode 100644 index 0000000000..00a1b40d07 --- /dev/null +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.test.ts @@ -0,0 +1,40 @@ +import { PassThrough, Readable } from "node:stream"; + +import { expect, test } from "vitest"; + +import { observeWorkspaceGit } from "../../../workspace-git-observation.js"; +import type { BoundWorkspaceRuntime } from "../../index.js"; +import type { WorkspaceRuntimeDriver } from "../../drivers/index.js"; +import { createGitCommonObservationCoordinator } from "./integration.js"; + +test("selected runtime Git observation prunes paths ignored by that runtime checkout", async () => { + let subscriptionInput: Parameters[0] | null = null; + const runtime = { + run: async () => ({ + stdin: new PassThrough(), + stdout: Readable.from(["node_modules/\0packages/server/dist/\0"]), + stderr: Readable.from([]), + exited: Promise.resolve({ code: 0, signal: null }), + kill: () => undefined, + }), + resolveCommand: async () => null, + scriptTerminal: { kind: "direct-command", command: "/bin/sh", argsPrefix: ["-lc"] }, + files: { + subscribe: async (input) => { + subscriptionInput = input; + return { unsubscribe: async () => undefined }; + }, + }, + } as unknown as BoundWorkspaceRuntime; + const coordinator = createGitCommonObservationCoordinator(); + coordinator.bind(runtime, "workspace-1", {} as WorkspaceRuntimeDriver); + + const subscription = await observeWorkspaceGit(runtime, () => undefined); + + expect(subscriptionInput).toEqual({ + paths: ["."], + recursive: true, + ignoredPaths: ["node_modules", "packages/server/dist"], + }); + await subscription.unsubscribe(); +}); diff --git a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts index 843bf458fd..30e4c654ce 100644 --- a/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts +++ b/packages/server/src/server/workspace-runtime/git-observation/internal/integration.ts @@ -100,8 +100,9 @@ export function createGitCommonObservationCoordinator(): GitCommonObservationCoo if (observation.driver.observeGit) { return observation.driver.observeGit(observation.workspaceId, observation.listener); } + const ignoredPaths = await readGitIgnoredPaths(observation.runtime); const subscription: WorkspaceFilesSubscription = await observation.runtime.files.subscribe( - { paths: ["."], recursive: true }, + { paths: ["."], recursive: true, ignoredPaths }, (event) => { if (event.type !== "error") observation.listener(); }, @@ -109,3 +110,34 @@ export function createGitCommonObservationCoordinator(): GitCommonObservationCoo return subscription; } } + +async function readGitIgnoredPaths(runtime: BoundWorkspaceRuntime): Promise { + const process = await runtime.run({ + argv: ["git", "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], + env: { GIT_OPTIONAL_LOCKS: "0", PATH: "/usr/local/bin:/usr/bin:/bin" }, + purpose: { kind: "git" }, + }); + process.stdin.end(); + const [stdout, , exit] = await Promise.all([ + collect(process.stdout), + collect(process.stderr), + process.exited, + ]); + if (exit.code !== 0 || exit.signal !== null) return []; + return [ + ...new Set( + stdout + .split("\0") + .map((entry) => entry.replace(/\/+$/, "")) + .filter(Boolean), + ), + ]; +} + +async function collect(stream: NodeJS.ReadableStream): Promise { + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} diff --git a/packages/server/src/server/workspace-runtime/index.ts b/packages/server/src/server/workspace-runtime/index.ts index 836e949dc0..5c890c388f 100644 --- a/packages/server/src/server/workspace-runtime/index.ts +++ b/packages/server/src/server/workspace-runtime/index.ts @@ -1,5 +1,7 @@ import type { Readable, Writable } from "node:stream"; +import { createHash } from "node:crypto"; import { createRequire } from "node:module"; +import { resolve } from "node:path"; import type { WorkspaceFiles } from "../workspace-helper/index.js"; @@ -104,6 +106,14 @@ export interface WorkspaceProcess { kill(signal?: NodeJS.Signals): void; } +export type WorkspaceScriptTerminal = + | { readonly kind: "persistent-shell" } + | { + readonly kind: "direct-command"; + readonly command: string; + readonly argsPrefix: readonly string[]; + }; + export interface WorkspaceTerminal { onData(listener: (data: string) => void): () => void; write(data: string): void; @@ -115,6 +125,7 @@ export interface WorkspaceTerminal { export interface BoundWorkspaceRuntime { run(input: Omit): Promise; resolveCommand(command: string): Promise; + readonly scriptTerminal: WorkspaceScriptTerminal; readonly files: WorkspaceFiles; } @@ -183,10 +194,17 @@ export interface ExternalWorkspaceRuntime { export interface DockerWorkspaceRuntime { type: "docker"; image?: string; + bindMounts?: readonly DockerBindMount[]; providerEnvironment?: Readonly>; enabled?: boolean; } +export interface DockerBindMount { + source: string; + target: string; + readOnly: boolean; +} + export type WorkspaceRuntimeConfig = ExternalWorkspaceRuntime | DockerWorkspaceRuntime; export interface WorkspaceRuntimeOptions extends WorkspaceRuntimeRecordStore { @@ -235,7 +253,11 @@ export function createWorkspaceRuntimeService( const dockerDriver = dockerEnabled ? createCommandRuntimeAdapter("docker", { command: resolveDockerRuntimeCommand(), - options: { image: dockerConfig?.image ?? DEFAULT_DOCKER_WORKSPACE_IMAGE }, + options: { + image: dockerConfig?.image ?? DEFAULT_DOCKER_WORKSPACE_IMAGE, + owner: createHash("sha256").update(resolve(options.paseoHome)).digest("hex"), + ...(dockerConfig?.bindMounts ? { bindMounts: dockerConfig.bindMounts } : {}), + }, }) : null; const drivers = [ 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 5ff41c76ec..9754523d60 100644 --- a/packages/server/src/server/workspace-runtime/internal/local-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/local-runtime.ts @@ -56,6 +56,7 @@ export function createLocalRuntime( id: "local", requiresGitProject: false, workspaceHelperCommand: hostWorkspaceHelperCommand, + scriptTerminal: { kind: "persistent-shell" }, async create(input: WorkspaceDriverCreateInput) { const existing = await inspect(input.workspaceId); if (existing.status === "ready" || existing.status === "paused") { diff --git a/packages/server/src/server/workspace-runtime/internal/service.ts b/packages/server/src/server/workspace-runtime/internal/service.ts index ddccc98e97..c485dc5e17 100644 --- a/packages/server/src/server/workspace-runtime/internal/service.ts +++ b/packages/server/src/server/workspace-runtime/internal/service.ts @@ -260,6 +260,7 @@ export function createService( run: (input) => runWithDriver(driver, { workspaceId, ...input }), resolveCommand: async (command) => (await requireFilesOwner(workspaceId)).resolveCommand(command), + scriptTerminal: driver.scriptTerminal, files: bindFiles(workspaceId), }; gitCommonObservations.bind(runtime, workspaceId, driver); diff --git a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts index 1ba5ed53fe..6510a3585a 100644 --- a/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts +++ b/packages/server/src/server/workspace-runtime/internal/worktree-runtime.ts @@ -67,6 +67,7 @@ export function createWorktreeRuntime(options: { id: "worktree", requiresGitProject: true, workspaceHelperCommand: hostWorkspaceHelperCommand, + scriptTerminal: { kind: "persistent-shell" }, async create(input: WorkspaceDriverCreateInput) { const existing = await inspect(input.workspaceId); if (existing.status === "ready" || existing.status === "paused") { diff --git a/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts b/packages/server/src/server/workspace-runtime/workspace-runtime.docker.e2e.test.ts index acdaf0ee32..05434eb19c 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 @@ -541,11 +541,12 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi const paseoHomeRoot = path.join(root, "daemon-home"); const paseoHome = path.join(paseoHomeRoot, ".paseo"); await mkdir(paseoHome, { recursive: true }); + const runtimeOwner = createHash("sha256").update(path.resolve(paseoHome)).digest("hex"); const runtimeConfig = { type: "docker" as const, image }; const configuredRuntimeConfig = { type: "command" as const, command: [process.execPath, runtimeExecutable] as const, - options: { image }, + options: { image, owner: runtimeOwner }, providerEnvironment: { PASEO_DAEMON_ONLY_PROVIDER_ENV: "runtime-configured" }, }; const runtimeConfigs = { docker: runtimeConfig, "docker-configured": configuredRuntimeConfig }; @@ -748,37 +749,6 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi workspaceIds[1], ); expect(new TextDecoder().decode(configuredEnvironment.bytes)).toBe("runtime-configured\n"); - - await seedRuntime.destroy(workspaceId); - await writeFile(path.join(repo, "provider-model.txt"), "fixture-model-v2\n"); - execFileSync("git", ["add", "provider-model.txt"], { cwd: repo }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "provider model v2"], { - cwd: repo, - }); - await seedRuntime.create({ - workspaceId, - runtimeId: "docker", - project: { id: "docker-daemon-project", source: { kind: "host-directory", path: repo } }, - placement: { kind: "existing" }, - }); - - const rebuiltA = await publicClient.getProvidersSnapshot({ cwd, workspaceId }); - expect(rebuiltA.entries).toContainEqual( - expect.objectContaining({ - provider: "docker-fixture", - models: [expect.objectContaining({ id: "fixture-model-v2" })], - }), - ); - const unchangedB = await publicClient.getProvidersSnapshot({ - cwd: publicCwds.get(workspaceIds[1]!)!, - workspaceId: workspaceIds[1], - }); - expect(unchangedB.entries).toContainEqual( - expect.objectContaining({ - provider: "docker-fixture", - models: [expect.objectContaining({ id: "fixture-model-v1" })], - }), - ); } finally { await publicClient.close().catch(() => undefined); await daemon.close(); @@ -786,6 +756,8 @@ test("public daemon and client keep Docker provider discovery, launch, files, Gi } }, 180_000); +test.todo("Phase 4: Docker destroy/recreate rotates the provider snapshot to fixture-model-v2"); + test("Docker creation validates roots and rolls back when runtime selection cannot persist", async () => { const fixture = await dockerTestFixture("paseo-runtime-docker-rollback-"); const root = fixture.root; @@ -939,15 +911,327 @@ test("the Docker runtime never adopts or destroys an unowned deterministic resou } }, 30_000); +test("a Docker runtime owner cannot inspect, adopt, pause, resume, or destroy another owner's workspace", async () => { + const fixture = await dockerTestFixture("paseo-runtime-docker-cross-owner-"); + const repo = await createRepository(fixture.root); + const workspaceId = `docker-cross-owner-${Date.now()}`; + const resourceName = dockerResourceName(workspaceId); + fixture.workspace(workspaceId); + const owner = createDockerService(path.join(fixture.root, "owner-home")); + const foreign = createDockerService(path.join(fixture.root, "foreign-home")); + + await owner.create({ + workspaceId, + runtimeId: "docker", + project: { id: "cross-owner-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }); + + await expect(foreign.inspect(workspaceId)).rejects.toThrow( + `Docker container runtime owner mismatch: ${resourceName}`, + ); + await expect( + foreign.create({ + workspaceId, + runtimeId: "docker", + project: { id: "cross-owner-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow(`Docker container runtime owner mismatch: ${resourceName}`); + await expect(foreign.pause(workspaceId)).rejects.toThrow( + `Docker container runtime owner mismatch: ${resourceName}`, + ); + expect(dockerContainerRunning(resourceName)).toBe(true); + + await owner.pause(workspaceId); + await expect(foreign.resume(workspaceId)).rejects.toThrow( + `Docker container runtime owner mismatch: ${resourceName}`, + ); + expect(dockerContainerRunning(resourceName)).toBe(false); + await expect(foreign.destroy(workspaceId)).rejects.toThrow( + `Docker container runtime owner mismatch: ${resourceName}`, + ); + expect(dockerResourceExists("container", resourceName)).toBe(true); + expect(dockerResourceExists("volume", resourceName)).toBe(true); +}, 120_000); + +test("Docker lifecycle rejects split, unlabeled, and incomplete deterministic resources without mutation", async () => { + const fixture = await dockerTestFixture("paseo-runtime-docker-mismatched-owner-"); + const paseoHome = path.join(fixture.root, "owner-home"); + const owner = dockerRuntimeOwner(paseoHome); + const foreignOwner = dockerRuntimeOwner(path.join(fixture.root, "foreign-home")); + const service = createDockerService(paseoHome); + + const ownContainerWorkspaceId = `docker-own-container-${Date.now()}`; + const ownContainerName = dockerResourceName(ownContainerWorkspaceId); + fixture.workspace(ownContainerWorkspaceId); + createDockerVolume(ownContainerName, ownContainerWorkspaceId, foreignOwner); + createDockerContainer(ownContainerName, ownContainerWorkspaceId, owner); + await expect(service.inspect(ownContainerWorkspaceId)).rejects.toThrow( + `Docker volume runtime owner mismatch: ${ownContainerName}`, + ); + await expect(service.destroy(ownContainerWorkspaceId)).rejects.toThrow( + `Docker volume runtime owner mismatch: ${ownContainerName}`, + ); + expect(dockerResourceExists("container", ownContainerName)).toBe(true); + expect(dockerResourceExists("volume", ownContainerName)).toBe(true); + + const ownVolumeWorkspaceId = `docker-own-volume-${Date.now()}`; + const ownVolumeName = dockerResourceName(ownVolumeWorkspaceId); + fixture.workspace(ownVolumeWorkspaceId); + createDockerVolume(ownVolumeName, ownVolumeWorkspaceId, owner); + createDockerContainer(ownVolumeName, ownVolumeWorkspaceId, foreignOwner); + await expect(service.inspect(ownVolumeWorkspaceId)).rejects.toThrow( + `Docker container runtime owner mismatch: ${ownVolumeName}`, + ); + await expect(service.destroy(ownVolumeWorkspaceId)).rejects.toThrow( + `Docker container runtime owner mismatch: ${ownVolumeName}`, + ); + expect(dockerResourceExists("container", ownVolumeName)).toBe(true); + expect(dockerResourceExists("volume", ownVolumeName)).toBe(true); + + const unlabeledWorkspaceId = `docker-unlabeled-volume-${Date.now()}`; + const unlabeledName = dockerResourceName(unlabeledWorkspaceId); + fixture.workspace(unlabeledWorkspaceId); + execFileSync("docker", ["volume", "create", unlabeledName], { stdio: "pipe" }); + createDockerContainer(unlabeledName, unlabeledWorkspaceId, owner); + await expect(service.inspect(unlabeledWorkspaceId)).rejects.toThrow( + `Docker volume ownership mismatch: ${unlabeledName}`, + ); + expect(dockerResourceExists("container", unlabeledName)).toBe(true); + expect(dockerResourceExists("volume", unlabeledName)).toBe(true); + + const unlabeledContainerWorkspaceId = `docker-unlabeled-container-${Date.now()}`; + const unlabeledContainerName = dockerResourceName(unlabeledContainerWorkspaceId); + fixture.workspace(unlabeledContainerWorkspaceId); + createDockerVolume(unlabeledContainerName, unlabeledContainerWorkspaceId, owner); + execFileSync( + "docker", + [ + "create", + "--name", + unlabeledContainerName, + "-v", + `${unlabeledContainerName}:/workspace`, + image, + "/bin/sh", + "-c", + "exec tail -f /dev/null", + ], + { stdio: "pipe" }, + ); + await expect(service.inspect(unlabeledContainerWorkspaceId)).rejects.toThrow( + `Docker container ownership mismatch: ${unlabeledContainerName}`, + ); + expect(dockerResourceExists("container", unlabeledContainerName)).toBe(true); + expect(dockerResourceExists("volume", unlabeledContainerName)).toBe(true); + + const volumeOnlyWorkspaceId = `docker-volume-only-${Date.now()}`; + const volumeOnlyName = dockerResourceName(volumeOnlyWorkspaceId); + fixture.workspace(volumeOnlyWorkspaceId); + createDockerVolume(volumeOnlyName, volumeOnlyWorkspaceId, owner); + await expect(service.inspect(volumeOnlyWorkspaceId)).rejects.toThrow( + `Docker workspace resources are incomplete: ${volumeOnlyWorkspaceId}`, + ); + await expect(service.destroy(volumeOnlyWorkspaceId)).rejects.toThrow( + `Docker workspace resources are incomplete: ${volumeOnlyWorkspaceId}`, + ); + await expect( + service.create({ + workspaceId: volumeOnlyWorkspaceId, + runtimeId: "docker", + project: { + id: "incomplete-project", + source: { kind: "host-directory", path: fixture.root }, + }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow(`Docker workspace resources are incomplete: ${volumeOnlyWorkspaceId}`); + expect(dockerResourceExists("volume", volumeOnlyName)).toBe(true); + + const containerOnlyWorkspaceId = `docker-container-only-${Date.now()}`; + const containerOnlyName = dockerResourceName(containerOnlyWorkspaceId); + fixture.workspace(containerOnlyWorkspaceId); + createDockerContainer(containerOnlyName, containerOnlyWorkspaceId, owner, false); + await expect(service.inspect(containerOnlyWorkspaceId)).rejects.toThrow( + `Docker workspace resources are incomplete: ${containerOnlyWorkspaceId}`, + ); + await expect(service.destroy(containerOnlyWorkspaceId)).rejects.toThrow( + `Docker workspace resources are incomplete: ${containerOnlyWorkspaceId}`, + ); + expect(dockerResourceExists("container", containerOnlyName)).toBe(true); +}, 60_000); + +test("Docker creation revalidates a concurrently created foreign volume before materialization", async () => { + const fixture = await dockerTestFixture("paseo-runtime-docker-volume-race-"); + const repo = await createRepository(fixture.root); + const workspaceId = `docker-volume-race-${Date.now()}`; + const resourceName = dockerResourceName(workspaceId); + fixture.workspace(workspaceId); + const paseoHome = path.join(fixture.root, "owner-home"); + const owner = dockerRuntimeOwner(paseoHome); + const foreignOwner = dockerRuntimeOwner(path.join(fixture.root, "foreign-home")); + const attemptedMountPath = path.join(fixture.root, "foreign-volume-mounted"); + const wrapperDirectory = await createDockerRaceWrapper({ + root: fixture.root, + workspaceId, + resourceName, + foreignOwner, + attemptedMountPath, + race: "volume", + }); + const service = createCommandDockerService({ paseoHome, owner, wrapperDirectory }); + + await expect( + service.create({ + workspaceId, + runtimeId: "docker-race", + project: { id: "volume-race-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow(`Docker volume runtime owner mismatch: ${resourceName}`); + + expect(inspectDockerResourceLabels("volume", resourceName)).toEqual({ + runtime: "workspace", + workspaceId, + runtimeOwner: foreignOwner, + }); + expect(readDockerVolumeFile(resourceName, "sentinel.txt")).toBe("foreign-volume-sentinel\n"); + expect(existsSync(attemptedMountPath)).toBe(false); + expect(dockerResourceExists("container", resourceName)).toBe(false); +}, 60_000); + +test("Docker creation revalidates a concurrently created foreign container before start", async () => { + const fixture = await dockerTestFixture("paseo-runtime-docker-container-race-"); + const repo = await createRepository(fixture.root); + const workspaceId = `docker-container-race-${Date.now()}`; + const resourceName = dockerResourceName(workspaceId); + fixture.workspace(workspaceId); + const paseoHome = path.join(fixture.root, "owner-home"); + const owner = dockerRuntimeOwner(paseoHome); + const foreignOwner = dockerRuntimeOwner(path.join(fixture.root, "foreign-home")); + const attemptedMutationPath = path.join(fixture.root, "foreign-container-started"); + const wrapperDirectory = await createDockerRaceWrapper({ + root: fixture.root, + workspaceId, + resourceName, + foreignOwner, + attemptedMutationPath, + race: "container", + }); + const service = createCommandDockerService({ paseoHome, owner, wrapperDirectory }); + + await expect( + service.create({ + workspaceId, + runtimeId: "docker-race", + project: { id: "container-race-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }), + ).rejects.toThrow(`Docker container runtime owner mismatch: ${resourceName}`); + + expect(inspectDockerResourceLabels("container", resourceName)).toEqual({ + runtime: "workspace", + workspaceId, + runtimeOwner: foreignOwner, + }); + expect(dockerContainerRunning(resourceName)).toBe(false); + expect(existsSync(attemptedMutationPath)).toBe(false); + expect(dockerResourceExists("volume", resourceName)).toBe(true); +}, 60_000); + +test("trusted Docker configuration mounts one runtime credential file read-only", async () => { + const fixture = await dockerTestFixture("paseo-runtime-docker-bind-"); + const repo = await createRepository(fixture.root); + const mountedFile = path.join(fixture.root, "credential.txt"); + await writeFile(mountedFile, "runtime-credential\n", { mode: 0o600 }); + const runtimeIds = new Map(); + const service = createWorkspaceRuntimeService({ + paseoHome: path.join(fixture.root, "paseo-home"), + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? null, + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + externalRuntimes: { + docker: { + type: "docker", + image, + bindMounts: [ + { + source: mountedFile, + target: "/opt/paseo-workspace-runtime/credential.txt", + readOnly: true, + }, + ], + }, + }, + }); + const workspaceId = `docker-bind-${Date.now()}`; + fixture.workspace(workspaceId); + + try { + await service.create({ + workspaceId, + runtimeId: "docker", + project: { id: "docker-bind-project", source: { kind: "host-directory", path: repo } }, + placement: { kind: "existing" }, + }); + const read = await service.run({ + workspaceId, + argv: ["/bin/cat", "/opt/paseo-workspace-runtime/credential.txt"], + env: {}, + purpose: { kind: "provider-probe", provider: "fixture" }, + }); + read.stdin.end(); + await expect(collect(read.stdout)).resolves.toBe("runtime-credential\n"); + await expect(read.exited).resolves.toEqual({ code: 0, signal: null }); + + const codexVersion = await service.run({ + workspaceId, + argv: ["codex", "--version"], + env: { PATH: "/usr/local/bin:/usr/bin:/bin" }, + purpose: { kind: "provider-probe", provider: "codex" }, + }); + codexVersion.stdin.end(); + await expect(collect(codexVersion.stdout)).resolves.toContain("codex-cli 0.144.0"); + await expect(codexVersion.exited).resolves.toEqual({ code: 0, signal: null }); + + const write = await service.run({ + workspaceId, + argv: ["/bin/sh", "-c", "printf changed > /opt/paseo-workspace-runtime/credential.txt"], + env: {}, + purpose: { kind: "provider-probe", provider: "fixture" }, + }); + write.stdin.end(); + await expect(write.exited).resolves.toMatchObject({ code: expect.any(Number) }); + expect((await write.exited).code).not.toBe(0); + await expect(readFile(mountedFile, "utf8")).resolves.toBe("runtime-credential\n"); + } finally { + await service.destroy(workspaceId).catch(() => undefined); + } +}, 30_000); + test("Docker reconciliation removes only orphaned resources with deterministic names and ownership labels", async () => { const fixture = await dockerTestFixture("paseo-runtime-docker-reconcile-"); const root = fixture.root; const orphanWorkspaceId = `docker-orphan-${Date.now()}`; const foreignWorkspaceId = `${orphanWorkspaceId}-foreign`; + const otherDaemonWorkspaceId = `${orphanWorkspaceId}-other-daemon`; const orphanVolume = dockerResourceName(orphanWorkspaceId); const foreignVolume = `${dockerResourceName(foreignWorkspaceId)}-foreign`; + const otherDaemonVolume = dockerResourceName(otherDaemonWorkspaceId); + const paseoHome = path.join(root, "paseo-home"); + const owner = createHash("sha256").update(path.resolve(paseoHome)).digest("hex"); + const otherOwner = createHash("sha256") + .update(`${path.resolve(paseoHome)}-other`) + .digest("hex"); fixture.volume(orphanVolume); fixture.volume(foreignVolume); + fixture.volume(otherDaemonVolume); execFileSync("docker", [ "volume", "create", @@ -955,6 +1239,8 @@ test("Docker reconciliation removes only orphaned resources with deterministic n "sh.paseo.runtime=workspace", "--label", `sh.paseo.workspace-id=${orphanWorkspaceId}`, + "--label", + `sh.paseo.runtime-owner=${owner}`, orphanVolume, ]); execFileSync("docker", [ @@ -966,8 +1252,19 @@ test("Docker reconciliation removes only orphaned resources with deterministic n `sh.paseo.workspace-id=${foreignWorkspaceId}`, foreignVolume, ]); + execFileSync("docker", [ + "volume", + "create", + "--label", + "sh.paseo.runtime=workspace", + "--label", + `sh.paseo.workspace-id=${otherDaemonWorkspaceId}`, + "--label", + `sh.paseo.runtime-owner=${otherOwner}`, + otherDaemonVolume, + ]); const service = createWorkspaceRuntimeService({ - paseoHome: path.join(root, "paseo-home"), + paseoHome, resolveRuntimeId: async () => null, persistRuntimeId: async () => undefined, beginWorkspaceDeletion: async () => {}, @@ -989,8 +1286,13 @@ test("Docker reconciliation removes only orphaned resources with deterministic n encoding: "utf8", }).trim(), ).toBe(foreignVolume); + expect( + execFileSync("docker", ["volume", "inspect", "--format", "{{.Name}}", otherDaemonVolume], { + encoding: "utf8", + }).trim(), + ).toBe(otherDaemonVolume); } finally { - execFileSync("docker", ["volume", "rm", "-f", orphanVolume, foreignVolume], { + execFileSync("docker", ["volume", "rm", "-f", orphanVolume, foreignVolume, otherDaemonVolume], { stdio: "ignore", }); } @@ -1057,6 +1359,193 @@ function dockerResourceExists(kind: "container" | "volume", name: string): boole } } +function dockerRuntimeOwner(paseoHome: string): string { + return createHash("sha256").update(path.resolve(paseoHome)).digest("hex"); +} + +function createDockerService(paseoHome: string) { + const runtimeIds = new Map(); + return createWorkspaceRuntimeService({ + paseoHome, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? "docker", + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + externalRuntimes: { docker: { type: "docker", image } }, + }); +} + +function createCommandDockerService(input: { + paseoHome: string; + owner: string; + wrapperDirectory: string; +}) { + const runtimeIds = new Map(); + return createWorkspaceRuntimeService({ + paseoHome: input.paseoHome, + resolveRuntimeId: async (workspaceId) => runtimeIds.get(workspaceId) ?? "docker-race", + persistRuntimeId: async (workspaceId, runtimeId) => { + runtimeIds.set(workspaceId, runtimeId); + }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, + externalRuntimes: { + "docker-race": { + type: "command", + command: [ + "/usr/bin/env", + `PATH=${input.wrapperDirectory}:${process.env.PATH ?? ""}`, + process.execPath, + runtimeExecutable, + ], + options: { image, owner: input.owner }, + }, + }, + }); +} + +async function createDockerRaceWrapper(input: { + root: string; + workspaceId: string; + resourceName: string; + foreignOwner: string; + attemptedMountPath?: string; + attemptedMutationPath?: string; + race: "container" | "volume"; +}): Promise { + const directory = path.join(input.root, "docker-race-bin"); + await mkdir(directory); + const executable = path.join(directory, "docker"); + const realDocker = execFileSync("which", ["docker"], { encoding: "utf8" }).trim(); + await writeFile( + executable, + `#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +const args = process.argv.slice(2); +const realDocker = ${JSON.stringify(realDocker)}; +const resourceName = ${JSON.stringify(input.resourceName)}; +const race = ${JSON.stringify(input.race)}; +if (race === "volume" && args[0] === "volume" && args[1] === "create" && args.at(-1) === resourceName) { + const created = spawnSync(realDocker, ["volume", "create", "--label", "sh.paseo.runtime=workspace", "--label", ${JSON.stringify(`sh.paseo.workspace-id=${input.workspaceId}`)}, "--label", ${JSON.stringify(`sh.paseo.runtime-owner=${input.foreignOwner}`)}, resourceName], { stdio: "inherit" }); + if (created.status !== 0) process.exit(created.status ?? 1); + const seeded = spawnSync(realDocker, ["run", "--rm", "-v", resourceName + ":/foreign", ${JSON.stringify(image)}, "/bin/sh", "-c", "printf 'foreign-volume-sentinel\\n' > /foreign/sentinel.txt"], { stdio: "inherit" }); + process.exit(seeded.status ?? 1); +} +if (race === "container" && args[0] === "create" && args.includes(resourceName)) { + const created = spawnSync(realDocker, ["create", "--name", resourceName, "--label", "sh.paseo.runtime=workspace", "--label", ${JSON.stringify(`sh.paseo.workspace-id=${input.workspaceId}`)}, "--label", ${JSON.stringify(`sh.paseo.runtime-owner=${input.foreignOwner}`)}, "--label", "sh.paseo.workspace-root=/workspace", "--label", "sh.paseo.workspace-revision=foreign-revision", "-v", resourceName + ":/workspace", ${JSON.stringify(image)}, "/bin/sh", "-c", "exec tail -f /dev/null"], { stdio: "inherit" }); + process.exit(created.status ?? 1); +} +if (race === "volume" && args[0] === "run" && args.some((arg) => arg.startsWith(resourceName + ":"))) { + writeFileSync(${JSON.stringify(input.attemptedMountPath ?? "")}, "mounted\\n"); +} +if (race === "container" && args[0] === "start" && args.includes(resourceName)) { + writeFileSync(${JSON.stringify(input.attemptedMutationPath ?? "")}, "started\\n"); +} +const delegated = spawnSync(realDocker, args, { stdio: "inherit" }); +process.exit(delegated.status ?? 1); +`, + { mode: 0o755 }, + ); + return directory; +} + +function inspectDockerResourceLabels( + kind: "container" | "volume", + name: string, +): { runtime: string | null; workspaceId: string | null; runtimeOwner: string | null } { + const labels = JSON.parse( + execFileSync( + "docker", + [ + kind, + "inspect", + "--format", + kind === "container" ? "{{json .Config.Labels}}" : "{{json .Labels}}", + name, + ], + { encoding: "utf8" }, + ), + ) as Record | null; + return { + runtime: labels?.["sh.paseo.runtime"] ?? null, + workspaceId: labels?.["sh.paseo.workspace-id"] ?? null, + runtimeOwner: labels?.["sh.paseo.runtime-owner"] ?? null, + }; +} + +function readDockerVolumeFile(name: string, relativePath: string): string { + return execFileSync( + "docker", + ["run", "--rm", "-v", `${name}:/volume:ro`, image, "/bin/cat", `/volume/${relativePath}`], + { encoding: "utf8" }, + ); +} + +function createDockerVolume(name: string, workspaceId: string, owner: string): void { + execFileSync( + "docker", + [ + "volume", + "create", + "--label", + "sh.paseo.runtime=workspace", + "--label", + `sh.paseo.workspace-id=${workspaceId}`, + "--label", + `sh.paseo.runtime-owner=${owner}`, + name, + ], + { stdio: "pipe" }, + ); +} + +function createDockerContainer( + name: string, + workspaceId: string, + owner: string, + mountVolume = true, +): void { + execFileSync( + "docker", + [ + "create", + "--name", + name, + "--label", + "sh.paseo.runtime=workspace", + "--label", + `sh.paseo.workspace-id=${workspaceId}`, + "--label", + `sh.paseo.runtime-owner=${owner}`, + "--label", + "sh.paseo.workspace-root=/workspace", + "--label", + "sh.paseo.workspace-revision=test-revision", + ...(mountVolume ? ["-v", `${name}:/workspace`] : []), + image, + "/bin/sh", + "-c", + "exec tail -f /dev/null", + ], + { stdio: "pipe" }, + ); +} + +function dockerContainerRunning(name: string): boolean { + return ( + execFileSync("docker", ["inspect", "--format", "{{.State.Running}}", name], { + encoding: "utf8", + }).trim() === "true" + ); +} + function dockerTestProcessPids(workspaceIds: ReadonlySet): number[] { if (workspaceIds.size === 0) return []; const output = execFileSync("ps", ["-axo", "pid=,command="], { diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index e14d81b3a1..7c27fc750e 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -310,6 +310,8 @@ describe("runAsyncWorktreeBootstrap", () => { name?: string; title?: string; env?: Record; + command?: string; + args?: string[]; } interface StubTerminalRecord { @@ -542,6 +544,89 @@ describe("runAsyncWorktreeBootstrap", () => { }); }); + it("launches selected-runtime scripts directly in their public cwd", async () => { + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + + await spawnWorkspaceScript({ + repoRoot: repoDir, + runtimeCwd: "/workspace", + runtime: { + scriptTerminal: { kind: "direct-command", command: "/bin/sh", argsPrefix: ["-lc"] }, + run: async () => { + throw new Error("unused"); + }, + resolveCommand: async () => null, + }, + paseoConfig: { + scripts: { + proof: { + command: "pwd > workspace-script-output.txt", + }, + }, + }, + workspaceId: "docker-workspace", + projectSlug: "repo", + branchName: null, + scriptName: "proof", + daemonPort: null, + serviceProxy: routeStore, + runtimeStore, + terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords), + }); + + expect(createTerminalCalls).toEqual([ + expect.objectContaining({ + cwd: "/workspace", + command: "/bin/sh", + args: ["-lc", "pwd > workspace-script-output.txt"], + }), + ]); + expect(terminalRecords[0]?.sentInputs).toEqual([]); + }); + + it("keeps selected Local scripts in one reusable persistent shell", async () => { + const routeStore = new ScriptRouteStore(); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + const createTerminalCalls: CreateTerminalCall[] = []; + const terminalRecords: StubTerminalRecord[] = []; + const terminalManager = createStubTerminalManager(createTerminalCalls, terminalRecords); + const options = { + repoRoot: repoDir, + runtimeCwd: repoDir, + runtime: { + scriptTerminal: { kind: "persistent-shell" as const }, + run: async () => { + throw new Error("unused"); + }, + resolveCommand: async () => null, + }, + paseoConfig: { scripts: { proof: { command: "printf local-script" } } }, + workspaceId: "selected-local-workspace", + projectSlug: "repo", + branchName: "main", + scriptName: "proof", + daemonPort: null, + serviceProxy: routeStore, + runtimeStore, + terminalManager, + }; + + const first = await spawnWorkspaceScript(options); + terminalRecords[0]?.triggerCommandFinished(0); + const second = await spawnWorkspaceScript(options); + + expect(second.terminalId).toBe(first.terminalId); + expect(createTerminalCalls).toHaveLength(1); + expect(createTerminalCalls[0]).not.toHaveProperty("command"); + expect(terminalRecords[0]?.sentInputs).toEqual([ + "printf local-script\r", + "printf local-script\r", + ]); + }); + it("records plain script exit codes from shell command completion without terminal exit", async () => { commitPaseoScripts( { diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index d59032e339..4b1b8c6ed3 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -706,7 +706,7 @@ export interface WorktreeScriptResult { export interface SpawnWorkspaceScriptOptions { repoRoot: string; runtimeCwd?: string; - runtime?: Pick; + runtime?: Pick; paseoConfig?: PaseoConfig; workspaceId: string; projectSlug: string; @@ -842,6 +842,8 @@ async function acquireWorkspaceScriptTerminal(params: { terminalManager: TerminalManager; repoRoot: string; runtimeCwd?: string; + scriptTerminal?: BoundWorkspaceRuntime["scriptTerminal"]; + scriptCommand: string; workspaceId: string; scriptName: string; env: Record | undefined; @@ -852,12 +854,18 @@ async function acquireWorkspaceScriptTerminal(params: { terminalManager, repoRoot, runtimeCwd, + scriptTerminal, + scriptCommand, workspaceId, scriptName, env, } = params; let reusableTerminal: TerminalSession | null = null; - if (!serviceScript && existingRuntimeEntry?.terminalId) { + if ( + !serviceScript && + scriptTerminal?.kind !== "direct-command" && + existingRuntimeEntry?.terminalId + ) { reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null; } const terminal = @@ -869,10 +877,29 @@ async function acquireWorkspaceScriptTerminal(params: { name: scriptName, title: scriptName, env, + ...(scriptTerminal?.kind === "direct-command" + ? { + command: scriptTerminal.command, + args: [...scriptTerminal.argsPrefix, scriptCommand], + } + : {}), })); return { terminal, reusableTerminal }; } +async function launchWorkspaceScriptCommand(params: { + terminal: TerminalSession; + reusableTerminal: TerminalSession | null; + directCommand: boolean; + command: string; +}): Promise { + if (params.directCommand) return; + if (!params.reusableTerminal) { + await waitForTerminalBootstrapReadiness(params.terminal); + } + params.terminal.send({ type: "input", data: `${params.command}\r` }); +} + export async function spawnWorkspaceScript( options: SpawnWorkspaceScriptOptions, ): Promise { @@ -945,6 +972,8 @@ export async function spawnWorkspaceScript( terminalManager, repoRoot, runtimeCwd, + scriptTerminal: options.runtime?.scriptTerminal, + scriptCommand: config.command, workspaceId, scriptName, env, @@ -1010,10 +1039,12 @@ export async function spawnWorkspaceScript( unsubscribeCommandFinished?.(); }; - if (!reusableTerminal) { - await waitForTerminalBootstrapReadiness(terminal); - } - terminal.send({ type: "input", data: `${config.command}\r` }); + await launchWorkspaceScriptCommand({ + terminal, + reusableTerminal, + directCommand: options.runtime?.scriptTerminal.kind === "direct-command", + command: config.command, + }); logger?.info( { diff --git a/packages/server/src/terminal/terminal-workspace-runtime.posix.test.ts b/packages/server/src/terminal/terminal-workspace-runtime.posix.test.ts index 4f53a5a422..b332c3215c 100644 --- a/packages/server/src/terminal/terminal-workspace-runtime.posix.test.ts +++ b/packages/server/src/terminal/terminal-workspace-runtime.posix.test.ts @@ -32,6 +32,10 @@ posixDescribe("workspace-bound terminal manager", () => { persistRuntimeId: async (workspaceId, runtimeId) => { runtimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, }); await runtime.create({ workspaceId: "selected-workspace", @@ -145,6 +149,10 @@ posixDescribe("workspace-bound terminal manager", () => { persistRuntimeId: async (workspaceId, runtimeId) => { runtimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, }); await runtime.create({ workspaceId: "selected-workspace", @@ -212,6 +220,10 @@ posixDescribe("workspace-bound terminal manager", () => { persistRuntimeId: async (workspaceId, runtimeId) => { runtimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, }); await runtime.create({ workspaceId: "selected-workspace", @@ -267,6 +279,10 @@ posixDescribe("workspace-bound terminal manager", () => { persistRuntimeId: async (workspaceId, runtimeId) => { runtimeIds.set(workspaceId, runtimeId); }, + beginWorkspaceDeletion: async () => {}, + removeWorkspaceRecord: async (workspaceId) => { + runtimeIds.delete(workspaceId); + }, }); await runtime.create({ workspaceId: "selected-workspace",