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",