mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
feat(workspaces): accept Docker runtime journeys
Exercise probe, creation, terminals, file observation, Git, scripts, and agent edits through the Desktop Docker runtime while proving host decoys remain untouched. Selected-runtime creation has a longer request budget because materialization and setup can legitimately exceed the legacy RPC ceiling; legacy creation keeps its existing timeout. Materializing records remain durable but private until final placement is published. Scope Docker resources to the daemon owner and validate both container and volume before every lifecycle operation and creation race. Explicit Local and Worktree selection remains behaviorally unchanged, including reusable Local script terminals and the shared common-Git watcher. Real-provider manual QA uses ignored checkout-local configuration and is intentionally not committed.
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -150,6 +150,11 @@ async function configureDesktopFixtureRuntime(paseoHome: string): Promise<void>
|
||||
__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<void>
|
||||
claude: { ...existing.agents?.providers?.claude, enabled: false },
|
||||
codex: { ...existing.agents?.providers?.codex, enabled: false },
|
||||
copilot: { ...existing.agents?.providers?.copilot, enabled: false },
|
||||
opencode: { ...existing.agents?.providers?.opencode, enabled: 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",
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
interface HostDecoySnapshot {
|
||||
content: string;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface SeededDockerRuntimeProject extends SeededRuntimeProject {
|
||||
hostMutationPath: string;
|
||||
expectHostDecoysUntouched(): Promise<void>;
|
||||
mutateHostAfterDockerCreation(): Promise<void>;
|
||||
}
|
||||
|
||||
const run = promisify(execFile);
|
||||
const DOCKER_CREATED_FILE = "made-in-container.txt";
|
||||
const DOCKER_SCRIPT_FILE = "workspace-script-output.txt";
|
||||
const DOCKER_AGENT_FILE = "stdio-agent-output.txt";
|
||||
const DOCKER_RUNTIME_LABELS = {
|
||||
runtime: "sh.paseo.runtime",
|
||||
runtimeOwner: "sh.paseo.runtime-owner",
|
||||
workspaceId: "sh.paseo.workspace-id",
|
||||
} as const;
|
||||
|
||||
const SETUP_MARKER = "runtime-user-setup-ran.txt";
|
||||
|
||||
export async function seedGitProjectForRuntime(
|
||||
@@ -37,6 +63,61 @@ export async function seedGitProjectForRuntime(
|
||||
return seedRuntimeProject(repo);
|
||||
}
|
||||
|
||||
export async function seedGitProjectWithDockerDecoys(): Promise<SeededDockerRuntimeProject> {
|
||||
const repo = await createTempGitRepo("runtime-docker-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ['node -e "setTimeout(() => {}, 65000)"'],
|
||||
},
|
||||
scripts: {
|
||||
"docker-proof": {
|
||||
command: "pwd > workspace-script-output.txt; sleep 10",
|
||||
},
|
||||
},
|
||||
},
|
||||
files: [
|
||||
{ path: "committed.txt", content: "committed host baseline\n" },
|
||||
{ path: "provider-model.txt", content: "docker-fixture-model\n" },
|
||||
{ path: "host-mutation.txt", content: "committed before Docker creation\n" },
|
||||
],
|
||||
});
|
||||
const decoyContents = new Map([
|
||||
[DOCKER_CREATED_FILE, "host decoy: terminal must not overwrite this\n"],
|
||||
[DOCKER_SCRIPT_FILE, "host decoy: script must not overwrite this\n"],
|
||||
[DOCKER_AGENT_FILE, "host decoy: agent must not overwrite this\n"],
|
||||
]);
|
||||
for (const [name, content] of decoyContents) {
|
||||
await writeFile(path.join(repo.path, name), content);
|
||||
}
|
||||
const snapshots = new Map<string, HostDecoySnapshot>();
|
||||
for (const name of decoyContents.keys()) {
|
||||
const filePath = path.join(repo.path, name);
|
||||
const metadata = await stat(filePath);
|
||||
snapshots.set(name, { content: await readFile(filePath, "utf8"), mtimeMs: metadata.mtimeMs });
|
||||
}
|
||||
const project = await seedRuntimeProject(repo);
|
||||
return {
|
||||
...project,
|
||||
hostMutationPath: path.join(repo.path, "host-mutation.txt"),
|
||||
mutateHostAfterDockerCreation: async () => {
|
||||
await writeFile(
|
||||
path.join(repo.path, "host-mutation.txt"),
|
||||
"host changed after Docker creation\n",
|
||||
);
|
||||
},
|
||||
expectHostDecoysUntouched: async () => {
|
||||
for (const [name, snapshot] of snapshots) {
|
||||
const filePath = path.join(repo.path, name);
|
||||
const metadata = await stat(filePath);
|
||||
expect(await readFile(filePath, "utf8"), `${name} host decoy content`).toBe(
|
||||
snapshot.content,
|
||||
);
|
||||
expect(metadata.mtimeMs, `${name} host decoy mtime`).toBe(snapshot.mtimeMs);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function readProbeRecords(projectId: string): Promise<Array<{ workspaceId: string }>> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
const records = JSON.parse(
|
||||
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
) as Array<{
|
||||
projectId: string;
|
||||
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<void> {
|
||||
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<void> {
|
||||
await expect(page.getByRole("button", { name: /Open workspace in/ })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Choose editor" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Open in file manager/ })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectDockerWorkspaceHasNoHostVisiblePath(
|
||||
project: SeededDockerRuntimeProject,
|
||||
): Promise<void> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
const records = JSON.parse(
|
||||
await readFile(path.join(paseoHome, "projects", "workspaces.json"), "utf8"),
|
||||
) as Array<{
|
||||
projectId: string;
|
||||
runtime?: { runtimeId: string };
|
||||
hostVisiblePath?: string;
|
||||
}>;
|
||||
const workspace = records.find(
|
||||
(record) => record.projectId === project.projectId && record.runtime?.runtimeId === "docker",
|
||||
);
|
||||
expect(workspace, "Docker user workspace record").toBeDefined();
|
||||
expect(workspace?.hostVisiblePath).toBeNull();
|
||||
}
|
||||
|
||||
export async function expectDockerTerminalText(page: Page, text: string): Promise<void> {
|
||||
await expect.poll(() => getTerminalBufferText(page), { timeout: 30_000 }).toContain(text);
|
||||
}
|
||||
|
||||
export async function expectDockerScriptTerminalOpen(
|
||||
page: Page,
|
||||
scriptName: string,
|
||||
): Promise<void> {
|
||||
const scriptTab = page
|
||||
.locator('[data-testid^="workspace-tab-terminal_"]')
|
||||
.filter({ hasText: scriptName });
|
||||
await expect(scriptTab).toBeVisible({ timeout: 30_000 });
|
||||
await scriptTab.click();
|
||||
}
|
||||
|
||||
export async function openDockerUncommittedChanges(page: Page): Promise<void> {
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
await page.getByRole("button", { name: "Diff mode", exact: true }).click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
await expect(page.getByRole("button", { name: "Diff mode", exact: true })).toHaveText(
|
||||
"Uncommitted",
|
||||
);
|
||||
}
|
||||
|
||||
export async function commitDockerWorkspaceChanges(page: Page): Promise<void> {
|
||||
await clickFirstTerminalTab(page);
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]').first();
|
||||
await terminal.click();
|
||||
await terminal.pressSequentially(
|
||||
"git add committed.txt made-in-container.txt && git -c user.name='Paseo E2E' -c user.email='paseo@example.test' commit -m 'Docker UI acceptance commit'\n",
|
||||
{ delay: 0 },
|
||||
);
|
||||
await expectDockerTerminalText(page, "Docker UI acceptance commit");
|
||||
}
|
||||
|
||||
export async function cleanupDockerRuntimeProject(
|
||||
project: SeededDockerRuntimeProject,
|
||||
): Promise<void> {
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME is not set");
|
||||
const recordPaths = [
|
||||
path.join(paseoHome, "projects", "provider-probes.json"),
|
||||
path.join(paseoHome, "projects", "workspaces.json"),
|
||||
];
|
||||
const workspaceIds: string[] = [];
|
||||
const userWorkspaceIds: string[] = [];
|
||||
for (const recordPath of recordPaths) {
|
||||
try {
|
||||
await access(recordPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const records = JSON.parse(await readFile(recordPath, "utf8")) as Array<{
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
runtimeId?: string;
|
||||
runtime?: { runtimeId: string };
|
||||
}>;
|
||||
const matchingWorkspaceIds = records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.projectId === project.projectId &&
|
||||
(record.runtimeId === "docker" || record.runtime?.runtimeId === "docker"),
|
||||
)
|
||||
.map((record) => record.workspaceId);
|
||||
workspaceIds.push(...matchingWorkspaceIds);
|
||||
if (recordPath.endsWith("workspaces.json")) userWorkspaceIds.push(...matchingWorkspaceIds);
|
||||
}
|
||||
const names = workspaceIds.map((workspaceId) => dockerResourceName(workspaceId));
|
||||
const userNames = userWorkspaceIds.map((workspaceId) => dockerResourceName(workspaceId));
|
||||
const runtimeOwner = createHash("sha256").update(path.resolve(paseoHome)).digest("hex");
|
||||
let productionCleanupComplete = false;
|
||||
try {
|
||||
const cleanupClient = await connectSeedClient();
|
||||
try {
|
||||
const agents = await cleanupClient.fetchAgents({ scope: "active" });
|
||||
const ownedAgents = agents.entries.filter((entry) =>
|
||||
userWorkspaceIds.includes(entry.agent.workspaceId ?? ""),
|
||||
);
|
||||
await Promise.all(ownedAgents.map((entry) => cleanupClient.cancelAgent(entry.agent.id)));
|
||||
await Promise.all(ownedAgents.map((entry) => cleanupClient.archiveAgent(entry.agent.id)));
|
||||
for (const workspaceId of userWorkspaceIds) {
|
||||
const scripts = await cleanupClient.listWorkspaceScripts(workspaceId);
|
||||
await Promise.all(
|
||||
scripts.scripts
|
||||
.filter((script) => script.lifecycle === "running")
|
||||
.map((script) => cleanupClient.stopWorkspaceScript(workspaceId, script.scriptName)),
|
||||
);
|
||||
const listed = await cleanupClient.listTerminals(undefined, undefined, { workspaceId });
|
||||
await Promise.all(
|
||||
listed.terminals.map((terminal) => cleanupClient.killTerminal(terminal.id)),
|
||||
);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await cleanupClient.listTerminals(undefined, undefined, { workspaceId })).terminals
|
||||
.length,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(0);
|
||||
}
|
||||
} finally {
|
||||
await cleanupClient.close();
|
||||
}
|
||||
await cleanupProjectThroughProductionLifecycle(project, userWorkspaceIds, userNames);
|
||||
productionCleanupComplete = true;
|
||||
await expectDockerRuntimeGone(userWorkspaceIds, userNames);
|
||||
} finally {
|
||||
await cleanupValidatedDockerResources(workspaceIds, runtimeOwner);
|
||||
await expectDockerRuntimeGone(workspaceIds, names);
|
||||
expect(productionCleanupComplete).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupProjectThroughProductionLifecycle(
|
||||
project: SeededDockerRuntimeProject,
|
||||
workspaceIds: readonly string[],
|
||||
names: readonly string[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
await project.cleanup();
|
||||
return;
|
||||
} catch (firstError) {
|
||||
const lingeringProcesses = (await dockerRuntimeLeaks(workspaceIds, names)).processes;
|
||||
if (lingeringProcesses.length > 0) {
|
||||
await run("kill", ["-KILL", ...lingeringProcesses.map(String)]);
|
||||
await expect
|
||||
.poll(async () => (await dockerRuntimeLeaks(workspaceIds, names)).processes.length, {
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(0);
|
||||
}
|
||||
try {
|
||||
await project.cleanup();
|
||||
} catch (retryError) {
|
||||
throw new Error(
|
||||
`Docker project cleanup failed through the production lifecycle after ${String(firstError)}`,
|
||||
{ cause: retryError },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectDockerRuntimeGone(
|
||||
workspaceIds: readonly string[],
|
||||
names: readonly string[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => dockerRuntimeLeaks(workspaceIds, names), { timeout: 15_000 })
|
||||
.toEqual({ containers: [], processes: [], volumes: [] });
|
||||
}
|
||||
|
||||
async function cleanupValidatedDockerResources(
|
||||
workspaceIds: readonly string[],
|
||||
runtimeOwner: string,
|
||||
): Promise<void> {
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const name = dockerResourceName(workspaceId);
|
||||
const [container, volume] = await Promise.all([
|
||||
inspectDockerTestResource("container", name),
|
||||
inspectDockerTestResource("volume", name),
|
||||
]);
|
||||
assertDockerTestResourceOwner(container, { name, runtimeOwner, workspaceId });
|
||||
assertDockerTestResourceOwner(volume, { name, runtimeOwner, workspaceId });
|
||||
if (container) await removeDockerTestResource("container", name);
|
||||
if (volume) await removeDockerTestResource("volume", name);
|
||||
}
|
||||
}
|
||||
|
||||
interface DockerTestResourceInspection {
|
||||
labels: Record<string, string>;
|
||||
name: string;
|
||||
}
|
||||
|
||||
async function inspectDockerTestResource(
|
||||
kind: "container" | "volume",
|
||||
name: string,
|
||||
): Promise<DockerTestResourceInspection | null> {
|
||||
try {
|
||||
const { stdout } = await run("docker", [kind, "inspect", name]);
|
||||
const [inspection] = JSON.parse(stdout) as Array<{
|
||||
Config?: { Labels?: Record<string, string> | null };
|
||||
Labels?: Record<string, string> | null;
|
||||
Name?: string;
|
||||
}>;
|
||||
return {
|
||||
labels: (kind === "container" ? inspection?.Config?.Labels : inspection?.Labels) ?? {},
|
||||
name: inspection?.Name ?? name,
|
||||
};
|
||||
} catch (error) {
|
||||
if (await dockerTestResourceMissing(kind, name)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertDockerTestResourceOwner(
|
||||
resource: DockerTestResourceInspection | null,
|
||||
expected: { name: string; runtimeOwner: string; workspaceId: string },
|
||||
): void {
|
||||
if (!resource) return;
|
||||
expect(resource.labels).toMatchObject({
|
||||
[DOCKER_RUNTIME_LABELS.runtime]: "workspace",
|
||||
[DOCKER_RUNTIME_LABELS.runtimeOwner]: expected.runtimeOwner,
|
||||
[DOCKER_RUNTIME_LABELS.workspaceId]: expected.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async function removeDockerTestResource(kind: "container" | "volume", name: string): Promise<void> {
|
||||
try {
|
||||
await run("docker", kind === "container" ? ["rm", "-f", name] : ["volume", "rm", "-f", name]);
|
||||
} catch (error) {
|
||||
if (await dockerTestResourceMissing(kind, name)) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function dockerTestResourceMissing(
|
||||
kind: "container" | "volume",
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await run("docker", [kind, "inspect", name]);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function dockerResourceName(workspaceId: string): string {
|
||||
const key = createHash("sha256").update(workspaceId).digest("hex").slice(0, 20);
|
||||
return `paseo-ws-${key}`;
|
||||
}
|
||||
|
||||
async function dockerRuntimeLeaks(
|
||||
workspaceIds: readonly string[],
|
||||
names: readonly string[],
|
||||
): Promise<{ containers: string[]; processes: number[]; volumes: string[] }> {
|
||||
const containers = await existingDockerResources("container", names);
|
||||
const volumes = await existingDockerResources("volume", names);
|
||||
const { stdout } = await run("ps", ["-axo", "pid=,command="]);
|
||||
const needles = workspaceIds.flatMap((workspaceId) => [
|
||||
dockerResourceName(workspaceId),
|
||||
`--workspace-id ${workspaceId}`,
|
||||
]);
|
||||
const processes = stdout
|
||||
.split("\n")
|
||||
.map((line) => line.match(/^\s*(\d+)\s+(.*)$/u))
|
||||
.filter((match): match is RegExpMatchArray =>
|
||||
Boolean(match && needles.some((needle) => match[2]?.includes(needle))),
|
||||
)
|
||||
.map((match) => Number(match[1]));
|
||||
return { containers, processes, volumes };
|
||||
}
|
||||
|
||||
async function existingDockerResources(
|
||||
kind: "container" | "volume",
|
||||
names: readonly string[],
|
||||
): Promise<string[]> {
|
||||
const checks = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
try {
|
||||
await run("docker", [kind, "inspect", name]);
|
||||
return name;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return checks.filter((name): name is string => name !== null);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
refreshAgent(agentId: string): Promise<unknown>;
|
||||
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?: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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<DockerContainerInspection | null> {
|
||||
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<void> {
|
||||
const current = await inspectPrivate(workspaceId);
|
||||
async function pause(workspaceId: string, expectedOwner: string): Promise<void> {
|
||||
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<PrivateRuntimeState> {
|
||||
const current = await inspectPrivate(workspaceId);
|
||||
async function resume(workspaceId: string, expectedOwner: string): Promise<PrivateRuntimeState> {
|
||||
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<void> {
|
||||
async function destroy(workspaceId: string, expectedOwner: string): Promise<void> {
|
||||
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<DockerResourceLabels | null> {
|
||||
const output = await docker(["volume", "inspect", name], true);
|
||||
if (!output) return null;
|
||||
const inspection = JSON.parse(output) as Array<{ Labels?: Record<string, string> | 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<void> {
|
||||
async function reconcile(workspaceIds: readonly string[], owner: string): Promise<void> {
|
||||
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<void> {
|
||||
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<Map<string, string>> {
|
||||
async function labeledResources(
|
||||
kind: "ps" | "volume",
|
||||
owner: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const output =
|
||||
kind === "ps"
|
||||
? await docker([
|
||||
@@ -351,6 +419,8 @@ async function labeledResources(kind: "ps" | "volume"): Promise<Map<string, stri
|
||||
"-a",
|
||||
"--filter",
|
||||
"label=sh.paseo.runtime=workspace",
|
||||
"--filter",
|
||||
`label=sh.paseo.runtime-owner=${owner}`,
|
||||
"--format",
|
||||
'{{.Names}}|{{.Label "sh.paseo.workspace-id"}}',
|
||||
])
|
||||
@@ -359,6 +429,8 @@ async function labeledResources(kind: "ps" | "volume"): Promise<Map<string, stri
|
||||
"ls",
|
||||
"--filter",
|
||||
"label=sh.paseo.runtime=workspace",
|
||||
"--filter",
|
||||
`label=sh.paseo.runtime-owner=${owner}`,
|
||||
"--format",
|
||||
'{{.Name}}|{{.Label "sh.paseo.workspace-id"}}',
|
||||
]);
|
||||
@@ -383,7 +455,8 @@ async function execute(workspaceId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>,
|
||||
expectedOwner: string,
|
||||
onSignal: (signal: NodeJS.Signals) => void,
|
||||
onResize: (id: number) => void,
|
||||
): Promise<void> {
|
||||
@@ -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<void> {
|
||||
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<Error[]> {
|
||||
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<void> => {
|
||||
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<string, unknown>;
|
||||
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}` };
|
||||
|
||||
@@ -963,14 +963,6 @@ export async function createPaseoDaemon(
|
||||
runtimeId: string,
|
||||
placement: { cwd: string; hostVisiblePath?: string },
|
||||
): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
+24
@@ -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"),
|
||||
|
||||
+4
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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<PersistedWorkspaceRecord>
|
||||
implements WorkspaceRegistry
|
||||
{
|
||||
private readonly provisionalWorkspaceIds = new Set<string>();
|
||||
private readonly mutationListeners = new Set<
|
||||
(mutation: WorkspaceMutation) => void | Promise<void>
|
||||
>();
|
||||
@@ -508,18 +508,19 @@ export class FileBackedWorkspaceRegistry
|
||||
|
||||
override async list(): Promise<PersistedWorkspaceRecord[]> {
|
||||
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<PersistedWorkspaceRecord | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.provisionalWorkspaceIds.delete(workspaceId);
|
||||
const workspace = await this.removeIfPresent(workspaceId);
|
||||
if (!workspace) return;
|
||||
await this.notifyMutation({ kind: "remove", workspaceId, workspace: null });
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<WorkspaceDriverCreation>;
|
||||
inspect(workspaceId: WorkspaceId): Promise<WorkspaceDriverInspection>;
|
||||
spawn(input: WorkspaceDriverSpawnInput): Promise<WorkspaceDriverProcess>;
|
||||
|
||||
+40
@@ -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<BoundWorkspaceRuntime["files"]["subscribe"]>[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();
|
||||
});
|
||||
+33
-1
@@ -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<string[]> {
|
||||
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<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceProcessInput, "workspaceId">): Promise<WorkspaceProcess>;
|
||||
resolveCommand(command: string): Promise<string | null>;
|
||||
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<Record<string, string>>;
|
||||
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 = [
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
+523
-34
@@ -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<string, string>();
|
||||
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<string, string>();
|
||||
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<string, string>();
|
||||
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<string> {
|
||||
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<string, string> | 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<string>): number[] {
|
||||
if (workspaceIds.size === 0) return [];
|
||||
const output = execFileSync("ps", ["-axo", "pid=,command="], {
|
||||
|
||||
@@ -310,6 +310,8 @@ describe("runAsyncWorktreeBootstrap", () => {
|
||||
name?: string;
|
||||
title?: string;
|
||||
env?: Record<string, string>;
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -706,7 +706,7 @@ export interface WorktreeScriptResult {
|
||||
export interface SpawnWorkspaceScriptOptions {
|
||||
repoRoot: string;
|
||||
runtimeCwd?: string;
|
||||
runtime?: Pick<BoundWorkspaceRuntime, "run" | "resolveCommand">;
|
||||
runtime?: Pick<BoundWorkspaceRuntime, "run" | "resolveCommand" | "scriptTerminal">;
|
||||
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<string, string> | 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<void> {
|
||||
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<WorktreeScriptResult> {
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user