Require explicit setup for fork pull requests (#4215)

* feat(workspaces): gate fork repository automation

Cross-repository change requests can execute contributor-controlled package hooks even when the setup command comes from the base repository. Persist the checkout provenance and require one explicit workspace-level setup action before repository automation runs.

* fix(workspaces): clarify blocked script errors

* fix(app): keep long toasts readable

* test(server): expect fork repository provenance

* test(server): make setup marker portable

* test(app): retry transient sidebar layout
This commit is contained in:
Mohamed Boudra
2026-09-02 20:50:22 +02:00
committed by GitHub
parent 2d8ff6f1cf
commit f75dff2322
44 changed files with 1309 additions and 166 deletions
+2
View File
@@ -50,6 +50,8 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
In Docker, the official image runs the daemon and agents as the non-root
+20 -19
View File
@@ -439,25 +439,26 @@ workspace together with its owning project.
Array of workspace records. A workspace is a specific working directory within a project.
| Field | Type | Description |
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| Field | Type | Description |
| ------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
| `createdAt` | `string` (ISO 8601) | |
| `updatedAt` | `string` (ISO 8601) | |
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
@@ -0,0 +1,112 @@
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { expect, test } from "../support/fixtures";
import { createTempGitRepo } from "../support/helpers/workspace";
import {
connectWorkspaceSetupClient,
openHomeWithProject,
openWorkspaceScriptsMenu,
seedProjectForWorkspaceSetup,
startWorkspaceScriptFromMenu,
} from "../support/helpers/workspace-setup";
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
import { getServerId } from "../support/helpers/server-id";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
const evidenceDir = "/tmp/fork-setup-gate";
test("fork workspace setup stays blocked until the user runs it", async ({ page }) => {
test.setTimeout(90_000);
mkdirSync(evidenceDir, { recursive: true });
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("fork-setup-gate-", {
withRemote: true,
originUrl: "https://github.com/paseo-e2e/local-fixture.git",
branches: ["pr-branch-2"],
paseoConfig: {
worktree: {
setup: ["node -e \"setTimeout(() => console.log('setup complete'), 1500)\""],
},
scripts: { dev: { command: 'node -e "setTimeout(() => {}, 30000)"' } },
},
});
try {
const localRemote = path.join(repo.path, "remote.git");
execFileSync("git", ["update-ref", "refs/pull/2/head", "refs/heads/pr-branch-2"], {
cwd: localRemote,
});
execFileSync(
"git",
["config", `url.${localRemote}.insteadOf`, "git@github.com:fork-owner/local-fixture.git"],
{ cwd: repo.path },
);
execFileSync(
"git",
[
"config",
"--add",
`url.${localRemote}.insteadOf`,
"https://github.com/paseo-e2e/local-fixture.git",
],
{ cwd: repo.path },
);
await seedProjectForWorkspaceSetup(client, repo.path);
const result = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repo.path,
action: "checkout",
checkoutSource: { kind: "change_request", forge: "github", number: 2 },
},
});
if (!result.workspace || result.error)
throw new Error(result.error ?? "Workspace creation failed");
expect(result.setupSkippedReason).toContain("Scripts are blocked for PR #2");
await openHomeWithProject(page, repo.path);
await page.goto(buildHostWorkspaceRoute(getServerId(), result.workspace.id));
await waitForWorkspaceTabsVisible(page);
await test.step("the blocked setup tab opens with the fork warning", async () => {
await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("alert")).toContainText("fork-owner/local-fixture");
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
await page.screenshot({
path: path.join(evidenceDir, "blocked-fork-setup.png"),
fullPage: true,
});
});
await test.step("starting a script explains why it is blocked", async () => {
await openWorkspaceScriptsMenu(page);
await startWorkspaceScriptFromMenu(page, "dev");
await expect(page.getByText(/Scripts are blocked for PR #2/)).toBeVisible();
await page.screenshot({
path: path.join(evidenceDir, "blocked-script-error.png"),
fullPage: true,
});
await page.reload();
await waitForWorkspaceTabsVisible(page);
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
});
await test.step("running setup replaces the warning with normal progress", async () => {
await page.getByRole("button", { name: "Run setup" }).click();
await expect(page.getByTestId("workspace-setup-status")).toContainText(/Running|Completed/, {
timeout: 30_000,
});
await page.screenshot({
path: path.join(evidenceDir, "fork-setup-running.png"),
fullPage: true,
});
await expect(page.getByTestId("workspace-setup-status")).toContainText("Completed", {
timeout: 30_000,
});
});
} finally {
await client.close();
await repo.cleanup();
}
});
+11 -9
View File
@@ -84,31 +84,33 @@ if (origin === fixtureRemote) {
process.exit(0);
}
if (command === "pr list" || command === "pr view") {
const isFork = args.includes("2");
const pr = {
number: 1,
number: isFork ? 2 : 1,
title: "Use pasted PR as start ref",
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
url: "https://github.com/paseo-e2e/local-fixture/pull/" + (isFork ? 2 : 1),
state: "OPEN",
body: null,
labels: [],
baseRefName: "main",
headRefName: "pr-branch-1",
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
updatedAt: "2026-01-01T00:00:00Z"
};
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
process.exit(0);
}
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
const isFork = args.some((arg) => arg === "number=2");
process.stdout.write(JSON.stringify({
data: { repository: { pullRequest: {
number: 1,
number: isFork ? 2 : 1,
baseRefName: "main",
headRefName: "pr-branch-1",
isCrossRepository: false,
headRepositoryOwner: { login: "paseo-e2e" },
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
isCrossRepository: isFork,
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
headRepository: {
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
url: fixtureRemote
sshUrl: isFork ? "git@github.com:fork-owner/local-fixture.git" : "git@github.com:paseo-e2e/local-fixture.git",
url: isFork ? "https://github.com/fork-owner/local-fixture" : fixtureRemote
}
} } }
}));
@@ -38,13 +38,9 @@ function itemLabel(key: SidebarNavKey): string {
}[key];
}
async function rowTop(locator: Locator): Promise<number> {
await locator.waitFor({ state: "visible" });
async function rowTop(locator: Locator): Promise<number | null> {
const box = await locator.boundingBox();
if (!box) {
throw new Error("Expected a laid-out sidebar row to measure");
}
return box.y;
return box?.y ?? null;
}
export async function seedSidebarNavPreferences(
@@ -140,6 +136,12 @@ async function expectVerticalOrder(
const measured = await Promise.all(
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
);
if (
!measured.every(
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
)
)
return null;
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
},
{ message: `Expected ${subject} in order`, timeout: 15_000 },
+27 -19
View File
@@ -43,6 +43,7 @@ export interface ToastApi {
type ToastViewportPlacement = "app-shell" | "panel";
const DEFAULT_DURATION_MS = 2200;
const TOAST_MAX_WIDTH = 480;
export function useToastHost(): {
api: ToastApi;
@@ -265,24 +266,26 @@ export function ToastViewport({
const content = (
<View style={styles.container} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={toastAnimatedStyle}
accessibilityRole="alert"
>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{typeof toast.content === "string" ? (
<Text testID="app-toast-message" style={toastMessageStyle}>
{toast.content}
</Text>
) : (
<View testID="app-toast-message" style={styles.contentSlot}>
{toast.content}
</View>
)}
</Animated.View>
<View style={styles.widthBoundary} pointerEvents="box-none">
<Animated.View
testID={toast.testID ?? "app-toast"}
onPointerEnter={isWeb ? pauseDismiss : undefined}
onPointerLeave={isWeb ? resumeDismiss : undefined}
style={toastAnimatedStyle}
accessibilityRole="alert"
>
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
{typeof toast.content === "string" ? (
<Text testID="app-toast-message" style={toastMessageStyle}>
{toast.content}
</Text>
) : (
<View testID="app-toast-message" style={styles.contentSlot}>
{toast.content}
</View>
)}
</Animated.View>
</View>
</View>
);
@@ -302,9 +305,14 @@ const styles = StyleSheet.create((theme) => ({
zIndex: OVERLAY_Z.toast,
alignItems: "center",
},
widthBoundary: {
width: "92%",
maxWidth: TOAST_MAX_WIDTH,
alignItems: "center",
},
toast: {
alignSelf: "center",
maxWidth: "92%",
maxWidth: "100%",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
+9
View File
@@ -503,14 +503,23 @@ export const ar: TranslationResources = {
completed: "اكتمل الإعداد",
failed: "فشل الإعداد",
workspace: "إعداد Workspace",
blocked: "تم حظر الإعداد",
},
status: {
running: "جري",
completed: "مكتمل",
failed: "فشل",
waiting: "في انتظار إخراج الإعداد",
blocked: "محظور",
},
waiting: "جارٍ إعداد مساحة العمل...",
blocked: {
title: "لم يتم تشغيل الإعداد",
description:
"يأتي طلب السحب هذا من {{repository}}، وهو مستودع مختلف. قد يشغّل الإعداد والبرامج النصية تعليمات برمجية لم تراجعها.",
run: "تشغيل الإعداد",
runFailed: "فشل تشغيل إعداد مساحة العمل",
},
empty: {
noCommands: "لم يتم تشغيل أي أوامر إعداد لمساحة العمل هذه.",
},
+9
View File
@@ -501,14 +501,23 @@ export const en = {
completed: "Setup completed",
failed: "Setup failed",
workspace: "Workspace setup",
blocked: "Setup blocked",
},
status: {
running: "Running",
completed: "Completed",
failed: "Failed",
waiting: "Waiting for setup output",
blocked: "Blocked",
},
waiting: "Setting up workspace...",
blocked: {
title: "Setup was not run",
description:
"This PR comes from {{repository}}, a different repository. Its setup and scripts could run code you have not reviewed.",
run: "Run setup",
runFailed: "Failed to run workspace setup",
},
empty: {
noCommands: "No setup commands ran for this workspace.",
},
+9
View File
@@ -507,14 +507,23 @@ export const es: TranslationResources = {
completed: "Configuración completada",
failed: "Error de configuración",
workspace: "Configuración deWorkspace",
blocked: "Configuración bloqueada",
},
status: {
running: "Correr",
completed: "Terminado",
failed: "Fallido",
waiting: "Esperando el resultado de la configuración",
blocked: "Bloqueado",
},
waiting: "Configurando el espacio de trabajo...",
blocked: {
title: "No se ejecutó la configuración",
description:
"Este PR proviene de {{repository}}, un repositorio diferente. Su configuración y scripts podrían ejecutar código que no has revisado.",
run: "Ejecutar configuración",
runFailed: "No se pudo ejecutar la configuración",
},
empty: {
noCommands: "No se ejecutó ningún comando de configuración para este espacio de trabajo.",
},
+9
View File
@@ -507,14 +507,23 @@ export const fr: TranslationResources = {
completed: "Configuration terminée",
failed: "Échec de l'installation",
workspace: "ConfigurationWorkspace",
blocked: "Configuration bloquée",
},
status: {
running: "En cours d'exécution",
completed: "Complété",
failed: "Échoué",
waiting: "En attente de la sortie de configuration",
blocked: "Bloqué",
},
waiting: "Configuration de l'espace de travail...",
blocked: {
title: "La configuration n’a pas été exécutée",
description:
"Cette PR provient de {{repository}}, un dépôt différent. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas examiné.",
run: "Exécuter la configuration",
runFailed: "Échec de la configuration de l’espace de travail",
},
empty: {
noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.",
},
+9
View File
@@ -507,14 +507,23 @@ export const ja: TranslationResources = {
completed: "セットアップ完了",
failed: "セットアップ失敗",
workspace: "ワークスペースセットアップ",
blocked: "セットアップはブロックされました",
},
status: {
running: "実行中",
completed: "完了",
failed: "失敗",
waiting: "セットアップ出力を待機中",
blocked: "ブロック済み",
},
waiting: "ワークスペースをセットアップ中...",
blocked: {
title: "セットアップは実行されませんでした",
description:
"このPRは別のリポジトリ {{repository}} からのものです。セットアップとスクリプトは未確認のコードを実行する可能性があります。",
run: "セットアップを実行",
runFailed: "ワークスペースのセットアップに失敗しました",
},
empty: {
noCommands: "このワークスペースでセットアップコマンドは実行されませんでした。",
},
+9
View File
@@ -504,14 +504,23 @@ export const ko: TranslationResources = {
completed: "설정 완료",
failed: "설정 실패",
workspace: "워크스페이스 설정",
blocked: "설정 차단됨",
},
status: {
running: "실행 중",
completed: "완료됨",
failed: "실패함",
waiting: "설정 출력을 기다리는 중",
blocked: "차단됨",
},
waiting: "워크스페이스 설정 중...",
blocked: {
title: "설정이 실행되지 않았습니다",
description:
"이 PR은 다른 저장소인 {{repository}}에서 왔습니다. 설정과 스크립트가 검토하지 않은 코드를 실행할 수 있습니다.",
run: "설정 실행",
runFailed: "워크스페이스 설정 실행 실패",
},
empty: {
noCommands: "이 워크스페이스에서 실행된 설정 명령이 없습니다.",
},
+9
View File
@@ -507,14 +507,23 @@ export const ptBR: TranslationResources = {
completed: "Configuração concluída",
failed: "Falha na configuração",
workspace: "Configuração do workspace",
blocked: "Configuração bloqueada",
},
status: {
running: "Em execução",
completed: "Concluído",
failed: "Falhou",
waiting: "Aguardando saída da configuração",
blocked: "Bloqueado",
},
waiting: "Configurando workspace...",
blocked: {
title: "A configuração não foi executada",
description:
"Este PR vem de {{repository}}, um repositório diferente. A configuração e os scripts podem executar código que você não revisou.",
run: "Executar configuração",
runFailed: "Falha ao executar a configuração do workspace",
},
empty: {
noCommands: "Nenhum comando de configuração foi executado para este workspace.",
},
+9
View File
@@ -506,14 +506,23 @@ export const ru: TranslationResources = {
completed: "Настройка завершена",
failed: "Настройка не удалась",
workspace: "Настройка рабочего пространства",
blocked: "Настройка заблокирована",
},
status: {
running: "Выполняется",
completed: "Завершено",
failed: "Завершено с ошибкой",
waiting: "Ожидание вывода команд настройки",
blocked: "Заблокировано",
},
waiting: "Настройка рабочего пространства...",
blocked: {
title: "Настройка не запускалась",
description:
"Этот PR поступил из другого репозитория — {{repository}}. Его настройка и скрипты могут выполнить код, который вы не проверяли.",
run: "Запустить настройку",
runFailed: "Не удалось запустить настройку рабочего пространства",
},
empty: {
noCommands: "Для этого рабочего пространства не выполнялись команды настройки.",
},
+9
View File
@@ -503,14 +503,23 @@ export const zhCN: TranslationResources = {
completed: "Setup 已完成",
failed: "Setup 失败",
workspace: "Workspace setup",
blocked: "设置已阻止",
},
status: {
running: "正在运行",
completed: "已完成",
failed: "失败",
waiting: "正在等待 setup 输出",
blocked: "已阻止",
},
waiting: "正在 setup workspace...",
blocked: {
title: "未运行设置",
description:
"此 PR 来自另一个仓库 {{repository}}。其设置和脚本可能会运行你尚未审查的代码。",
run: "运行设置",
runFailed: "运行工作区设置失败",
},
empty: {
noCommands: "此 workspace 没有运行 setup 命令。",
},
+182 -78
View File
@@ -1,4 +1,6 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
import { useTranslation } from "react-i18next";
@@ -15,6 +17,7 @@ import {
type WorkspaceSetupSnapshot,
} from "@/stores/workspace-setup-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useHostFeature } from "@/runtime/host-features";
function useSetupPanelDescriptor(
target: { kind: "setup"; workspaceId: string },
@@ -49,6 +52,17 @@ function useSetupPanelDescriptor(
};
}
if (snapshot?.status === "blocked") {
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.blocked"),
tooltip: t("workspace.setup.descriptor.blocked"),
titleState: "ready",
icon: CircleAlert,
statusBucket: null,
};
}
return {
label: t("workspace.setup.descriptor.label"),
subtitle: t("workspace.setup.descriptor.workspace"),
@@ -100,6 +114,55 @@ type SetupCommand = WorkspaceSetupSnapshot["detail"]["commands"][number];
const EMPTY_COMMANDS: SetupCommand[] = [];
function BlockedSetupNotice({
serverId,
workspaceId,
source,
}: {
serverId: string;
workspaceId: string;
source: NonNullable<WorkspaceSetupSnapshot["blockedSource"]>;
}) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
const supportsSetupRun = useHostFeature(serverId, "workspaceSetupRun");
const [runError, setRunError] = useState<string | null>(null);
const [isRunningSetup, setIsRunningSetup] = useState(false);
const runSetup = useCallback(async () => {
if (!client) return;
setRunError(null);
setIsRunningSetup(true);
try {
const response = await client.runWorkspaceSetup(workspaceId);
if (response.error) throw new Error(response.error);
} catch (error) {
setRunError(error instanceof Error ? error.message : t("workspace.setup.blocked.runFailed"));
} finally {
setIsRunningSetup(false);
}
}, [client, t, workspaceId]);
return (
<>
<Alert
variant="warning"
title={t("workspace.setup.blocked.title")}
description={t("workspace.setup.blocked.description", {
repository: source.headRepository,
})}
testID="workspace-setup-blocked"
>
{supportsSetupRun ? (
<Button accessibilityRole="button" onPress={runSetup} loading={isRunningSetup} size="sm">
{t("workspace.setup.blocked.run")}
</Button>
) : null}
</Alert>
{runError ? <Alert variant="error" description={runError} /> : null}
</>
);
}
function resolveAutoExpandIndex(commands: { index: number; status: string }[]): number | null {
const running = commands.find((c) => c.status === "running");
if (running) return running.index;
@@ -109,11 +172,12 @@ function resolveAutoExpandIndex(commands: { index: number; status: string }[]):
function resolveSetupStatusLabel(
status: string | undefined,
labels: Record<CommandStatus | "waiting", string>,
labels: Record<CommandStatus | "blocked" | "waiting", string>,
): string {
if (status === "running") return labels.running;
if (status === "completed") return labels.completed;
if (status === "failed") return labels.failed;
if (status === "blocked") return labels.blocked;
return labels.waiting;
}
@@ -151,26 +215,18 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
}
function SetupPanel() {
const { t } = useTranslation();
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
const client = useHostRuntimeClient(serverId);
const key = buildWorkspaceTabPersistenceKey({
serverId,
workspaceId: target.workspaceId,
});
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// On mount, if no snapshot in the store, request cached status from server
const requestedRef = useRef(false);
useEffect(() => {
if (snapshot || requestedRef.current || !client) return;
requestedRef.current = true;
client
.fetchWorkspaceSetupStatus(target.workspaceId)
.fetchWorkspaceSetupStatus(workspaceId)
.then((response) => {
if (response.snapshot) {
upsertProgress({
@@ -181,33 +237,26 @@ function SetupPanel() {
return;
})
.catch(() => {
// Server may not support this yet — ignore
// Server may not support this yet — ignore.
});
}, [client, snapshot, serverId, target.workspaceId, upsertProgress]);
}, [client, snapshot, serverId, upsertProgress, workspaceId]);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
return snapshot;
}
function useExpandedSetupCommands() {
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<number>>(new Set());
const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => {
setExpandedIndices((prev) => {
const next = new Set(prev);
if (next.has(index) || isAutoExpanded) {
next.delete(index);
// If this was auto-expanded, record that the user manually collapsed it
if (isAutoExpanded) {
setManuallyCollapsed((mc) => new Set(mc).add(index));
}
if (isAutoExpanded) setManuallyCollapsed((current) => new Set(current).add(index));
} else {
next.add(index);
// If the user re-expands, remove from manually collapsed
setManuallyCollapsed((mc) => {
const updated = new Set(mc);
setManuallyCollapsed((current) => {
const updated = new Set(current);
updated.delete(index);
return updated;
});
@@ -215,6 +264,23 @@ function SetupPanel() {
return next;
});
}, []);
return { expandedIndices, manuallyCollapsed, toggleExpanded };
}
function SetupPanel() {
const { t } = useTranslation();
const { serverId, target } = usePaneContext();
invariant(target.kind === "setup", "SetupPanel requires setup target");
const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId);
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
const log = snapshot?.detail.log ?? "";
const hasNoSetupCommands =
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands();
const autoExpandIndex = resolveAutoExpandIndex(commands);
const statusLabels = useMemo(
@@ -222,6 +288,7 @@ function SetupPanel() {
running: t("workspace.setup.status.running"),
completed: t("workspace.setup.status.completed"),
failed: t("workspace.setup.status.failed"),
blocked: t("workspace.setup.status.blocked"),
waiting: t("workspace.setup.status.waiting"),
}),
[t],
@@ -238,60 +305,97 @@ function SetupPanel() {
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
{statusLabel}
</Text>
{isWaiting ? (
<View style={styles.waitingContainer}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
</View>
) : null}
{!isWaiting && hasNoSetupCommands ? (
<View style={styles.emptyContainer}>
<Text
style={styles.emptyText}
accessible
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
>
{t("workspace.setup.empty.noCommands")}
</Text>
</View>
) : null}
{!isWaiting && !hasNoSetupCommands ? (
<View style={styles.commandList}>
{commands.map((command) => {
const rowState = buildCommandRowState({
command,
autoExpandIndex,
log,
expandedIndices,
manuallyCollapsed,
snapshotError: snapshot?.error,
});
return (
<SetupCommandRow
key={`${command.index}:${command.command}`}
command={command}
showDetail={rowState.showDetail}
isAutoExpanded={rowState.isAutoExpanded}
isExpandable={rowState.isExpandable}
hasLog={rowState.hasLog}
hasError={rowState.hasError}
processedLog={rowState.processedLog}
errorMessage={snapshot?.error ?? null}
onToggle={toggleExpanded}
/>
);
})}
<StandaloneLogView commands={commands} log={log} />
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
</View>
) : null}
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
</ScrollView>
);
}
interface SetupPanelBodyProps {
serverId: string;
workspaceId: string;
snapshot: WorkspaceSetupSnapshot | null;
commands: SetupCommand[];
log: string;
isWaiting: boolean;
hasNoSetupCommands: boolean;
autoExpandIndex: number | null;
expandedIndices: Set<number>;
manuallyCollapsed: Set<number>;
onToggle: (index: number, isAutoExpanded: boolean) => void;
}
function SetupPanelBody(props: SetupPanelBodyProps) {
const { t } = useTranslation();
const { snapshot, commands, log } = props;
if (snapshot?.status === "blocked" && snapshot.blockedSource) {
return (
<BlockedSetupNotice
serverId={props.serverId}
workspaceId={props.workspaceId}
source={snapshot.blockedSource}
/>
);
}
if (props.isWaiting) {
return (
<View style={styles.waitingContainer}>
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
</View>
);
}
if (props.hasNoSetupCommands) {
return (
<View style={styles.emptyContainer}>
<Text
style={styles.emptyText}
accessible
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
>
{t("workspace.setup.empty.noCommands")}
</Text>
</View>
);
}
return (
<View style={styles.commandList}>
{commands.map((command) => {
const rowState = buildCommandRowState({
command,
autoExpandIndex: props.autoExpandIndex,
log,
expandedIndices: props.expandedIndices,
manuallyCollapsed: props.manuallyCollapsed,
snapshotError: snapshot?.error,
});
return (
<SetupCommandRow
key={`${command.index}:${command.command}`}
command={command}
{...rowState}
errorMessage={snapshot?.error ?? null}
onToggle={props.onToggle}
/>
);
})}
<StandaloneLogView commands={commands} log={log} />
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
</View>
);
}
interface SetupCommandRowProps {
command: SetupCommand;
showDetail: boolean;
@@ -221,11 +221,12 @@ describe("workspace-setup-store", () => {
).toBe(true);
});
it("seeds a setup tab only for failed setup", () => {
it("seeds a setup tab for failed or blocked setup", () => {
expect(shouldSeedWorkspaceSetupTab(null)).toBe(false);
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("running"))).toBe(false);
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("completed"))).toBe(false);
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("failed", "Setup failed"))).toBe(true);
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("blocked"))).toBe(true);
});
it("claims one failed setup surface until a later setup lifecycle begins", () => {
@@ -34,11 +34,13 @@ export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null
if (!snapshot) {
return false;
}
return snapshot.error !== null || snapshot.detail.commands.length > 0;
return (
snapshot.status === "blocked" || snapshot.error !== null || snapshot.detail.commands.length > 0
);
}
export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | null): boolean {
return snapshot?.status === "failed";
return snapshot?.status === "failed" || snapshot?.status === "blocked";
}
interface WorkspaceSetupStoreState {
@@ -82,7 +84,7 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
set((state) => {
const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys);
if (payload.status !== "failed") {
if (payload.status !== "failed" && payload.status !== "blocked") {
surfacedFailedSetupKeys.delete(key);
}
return {
@@ -105,7 +107,10 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
let claimed = false;
set((state) => {
if (state.snapshots[key]?.status !== "failed" || state.surfacedFailedSetupKeys.has(key)) {
if (
!["failed", "blocked"].includes(state.snapshots[key]?.status ?? "") ||
state.surfacedFailedSetupKeys.has(key)
) {
return state;
}
claimed = true;
+1
View File
@@ -575,6 +575,7 @@ async function resolveRunWorkspace(
const branch = result.workspace.gitRuntime?.currentBranch;
const label = branch ? `${result.workspace.name} (${branch})` : result.workspace.name;
console.error(`Created workspace ${result.workspace.id} - ${label}`);
if (result.setupSkippedReason) console.error(result.setupSkippedReason);
console.error(
"Tip: pass --workspace <id> (or set PASEO_WORKSPACE_ID) to run in an existing workspace.",
);
@@ -5,10 +5,18 @@ import { runArchiveCommand } from "./archive.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runRenameCommand } from "./rename.js";
import { runSetupCommand } from "./setup.js";
export function createWorkspaceCommand(): Command {
const workspace = new Command("workspace").description("Manage workspaces");
addJsonAndDaemonHostOptions(
workspace
.command("setup")
.description("Allow and run setup for a workspace")
.argument("<workspace-id>", "Workspace id"),
).action(withOutput(runSetupCommand));
addJsonAndDaemonHostOptions(
workspace
.command("create")
@@ -0,0 +1,49 @@
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
interface WorkspaceSetupResult {
workspaceId: string;
status: "started" | "already_allowed";
}
const workspaceSetupSchema: OutputSchema<WorkspaceSetupResult> = {
idField: "workspaceId",
columns: [
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
{ header: "STATUS", field: "status", width: 18 },
],
};
export async function runSetupCommand(
workspaceId: string,
options: { host?: string },
_command: Command,
): Promise<SingleResult<WorkspaceSetupResult>> {
const host = getDaemonHost({ host: options.host });
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${error instanceof Error ? error.message : String(error)}`,
} satisfies CommandError;
});
try {
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
if (!client.getLastServerInfoMessage()?.features?.workspaceSetupRun) {
throw { code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to run workspace setup." };
}
const payload = await client.runWorkspaceSetup(workspaceId);
if (payload.error) throw new Error(payload.error);
return {
type: "single",
data: { workspaceId, status: payload.started ? "started" : "already_allowed" },
schema: workspaceSetupSchema,
};
} catch (error) {
if (error && typeof error === "object" && "code" in error) throw error;
const message = error instanceof Error ? error.message : String(error);
throw { code: "WORKSPACE_SETUP_FAILED", message } satisfies CommandError;
} finally {
await client.close().catch(() => undefined);
}
}
+10
View File
@@ -2392,6 +2392,16 @@ export class DaemonClient {
});
}
async runWorkspaceSetup(
workspaceId: string,
requestId?: string,
): Promise<Extract<SessionOutboundMessage, { type: "workspace.setup.run.response" }>["payload"]> {
return this.sendNamespacedCorrelatedSessionRequest<"workspace.setup.run.response">({
requestId,
message: { type: "workspace.setup.run.request", workspaceId },
});
}
async fetchAgent(options: FetchAgentOptions): Promise<FetchAgentResult | null>;
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null>;
async fetchAgent(
+44 -2
View File
@@ -2459,6 +2459,12 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
requestId: z.string(),
});
export const WorkspaceSetupRunRequestSchema = z.object({
type: z.literal("workspace.setup.run.request"),
workspaceId: z.string(),
requestId: z.string(),
});
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
export const LegacyListAvailableEditorsRequestSchema = z.object({
type: z.literal("list_available_editors_request"),
@@ -3153,6 +3159,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
PaseoWorktreeArchiveRequestSchema,
CreatePaseoWorktreeRequestSchema,
WorkspaceSetupStatusRequestSchema,
WorkspaceSetupRunRequestSchema,
LegacyListAvailableEditorsRequestSchema,
LegacyOpenInEditorRequestSchema,
OpenProjectRequestSchema,
@@ -3391,6 +3398,8 @@ export const ServerInfoStatusPayloadSchema = z
directorySync: z.boolean().optional(),
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
workspaceLabels: z.boolean().optional(),
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
workspaceSetupRun: z.boolean().optional(),
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
// feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17
// once the supported daemon floor is >= v0.2.0.
@@ -4156,16 +4165,42 @@ export const WorkspaceSetupProgressMessageSchema = z.object({
type: z.literal("workspace_setup_progress"),
payload: z.object({
workspaceId: z.string(),
status: z.enum(["running", "completed", "failed"]),
status: z.enum(["running", "completed", "failed", "blocked"]),
detail: WorktreeSetupDetailPayloadSchema,
error: z.string().nullable(),
blockedSource: z
.object({
kind: z.literal("change_request"),
forge: z.string(),
number: z.number().int().positive(),
headRepository: z.string(),
})
.optional(),
}),
});
export const WorkspaceSetupSnapshotSchema = z.object({
status: z.enum(["running", "completed", "failed"]),
status: z.enum(["running", "completed", "failed", "blocked"]),
detail: WorktreeSetupDetailPayloadSchema,
error: z.string().nullable(),
blockedSource: z
.object({
kind: z.literal("change_request"),
forge: z.string(),
number: z.number().int().positive(),
headRepository: z.string(),
})
.optional(),
});
export const WorkspaceSetupRunResponseMessageSchema = z.object({
type: z.literal("workspace.setup.run.response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
started: z.boolean(),
error: z.string().nullable(),
}),
});
export const WorkspaceSetupStatusResponseMessageSchema = z.object({
@@ -4564,6 +4599,7 @@ export const WorkspaceCreateResponseSchema = z.object({
payload: z.object({
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
setupTerminalId: z.string().nullable(),
setupSkippedReason: z.string().optional(),
error: z.string().nullable(),
errorCode: z.string().optional(),
requestId: z.string(),
@@ -5596,6 +5632,7 @@ export const CreatePaseoWorktreeResponseSchema = z.object({
error: z.string().nullable(),
errorCode: z.string().optional(),
setupTerminalId: z.string().nullable(),
setupSkippedReason: z.string().optional(),
requestId: z.string(),
}),
});
@@ -6400,6 +6437,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ScriptStatusUpdateMessageSchema,
WorkspaceSetupProgressMessageSchema,
WorkspaceSetupStatusResponseMessageSchema,
WorkspaceSetupRunResponseMessageSchema,
AgentStreamMessageSchema,
AgentStatusMessageSchema,
FetchAgentsResponseMessageSchema,
@@ -6578,6 +6616,9 @@ export type WorkspaceSetupSnapshot = z.infer<typeof WorkspaceSetupSnapshotSchema
export type WorkspaceSetupStatusResponseMessage = z.infer<
typeof WorkspaceSetupStatusResponseMessageSchema
>;
export type WorkspaceSetupRunResponseMessage = z.infer<
typeof WorkspaceSetupRunResponseMessageSchema
>;
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
@@ -6913,6 +6954,7 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
export type WorkspaceSetupRunRequest = z.infer<typeof WorkspaceSetupRunRequestSchema>;
export type LegacyListAvailableEditorsRequest = z.infer<
typeof LegacyListAvailableEditorsRequestSchema
>;
@@ -10,6 +10,52 @@ import {
} from "./messages.js";
describe("workspace message schemas", () => {
test("parses blocked setup status and the explicit setup run RPC", () => {
expect(
SessionInboundMessageSchema.parse({
type: "workspace.setup.run.request",
workspaceId: "workspace-fork",
requestId: "run-setup",
}),
).toMatchObject({ type: "workspace.setup.run.request", workspaceId: "workspace-fork" });
expect(
SessionOutboundMessageSchema.parse({
type: "workspace_setup_progress",
payload: {
workspaceId: "workspace-fork",
status: "blocked",
detail: {
type: "worktree_setup",
worktreePath: "/repo/fork",
branchName: "fork",
log: "",
commands: [],
},
error: null,
blockedSource: {
kind: "change_request",
forge: "github",
number: 42,
headRepository: "contributor/paseo",
},
},
}),
).toMatchObject({ payload: { status: "blocked", blockedSource: { number: 42 } } });
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.setup.run.response",
payload: {
requestId: "run-setup",
workspaceId: "workspace-fork",
started: true,
error: null,
},
}),
).toMatchObject({ type: "workspace.setup.run.response", payload: { started: true } });
});
test("parses fetch_workspaces_request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "fetch_workspaces_request",
@@ -198,6 +198,7 @@ const INBOUND_PERMISSION = {
"workspace.script.stop.request": "workspace.write",
"workspace.title.set.request": "workspace.manage",
workspace_setup_status_request: "workspace.read",
"workspace.setup.run.request": "workspace.write",
write_project_config_request: "workspace.write",
} as const satisfies Record<InboundOperation, DaemonPermission | null>;
@@ -409,6 +410,7 @@ const OUTBOUND_PERMISSION = {
"workspace.title.set.response": "workspace.manage",
workspace_setup_progress: "workspace.read",
workspace_setup_status_response: "workspace.read",
"workspace.setup.run.response": "workspace.write",
workspace_update: "workspace.read",
write_project_config_response: "workspace.write",
} as const satisfies Record<OutboundOperation, DaemonPermission | null>;
+9
View File
@@ -189,6 +189,7 @@ import { ScriptHealthMonitor } from "./script-health-monitor.js";
import { createScriptStatusEmitter } from "./script-status-projection.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import { createWorkspaceScriptsService } from "./session/workspace-scripts/workspace-scripts-service.js";
import { assertWorkspaceAutomationAllowedForWorkspace } from "./workspace-automation-gate.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
import {
createManagedProcessRegistry,
@@ -1120,6 +1121,8 @@ export async function createPaseoDaemon(
cacheWorkspaceSetupSnapshot: () => {},
startWorkspaceSetup: (workspaceId, operation) =>
workspaceSetupRuntime.start(workspaceId, operation),
assertWorkspaceAutomationAllowed: (guardedWorkspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, guardedWorkspaceId),
emit: emitExternalSessionMessage,
sessionLogger: logger,
terminalManager,
@@ -1168,6 +1171,8 @@ export async function createPaseoDaemon(
killTerminalsForWorkspace: (workspaceIdToKill) =>
killTerminalsForWorkspace({ terminalManager, sessionLogger: logger }, workspaceIdToKill),
stopWorkspaceSetup: (workspaceIdToStop) => workspaceSetupRuntime.stop(workspaceIdToStop),
assertWorkspaceAutomationAllowed: (guardedWorkspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, guardedWorkspaceId),
sessionLogger: logger,
},
{ scope: { kind: "workspace", workspaceId }, requestId },
@@ -1293,6 +1298,8 @@ export async function createPaseoDaemon(
workspaceIdToKill,
),
stopWorkspaceSetup: (workspaceIdToStop) => workspaceSetupRuntime.stop(workspaceIdToStop),
assertWorkspaceAutomationAllowed: (guardedWorkspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, guardedWorkspaceId),
sessionLogger: logger,
},
{
@@ -1374,6 +1381,8 @@ export async function createPaseoDaemon(
// status updates fan out to every connected client.
emit: (message) => wsServer?.broadcast(wrapSessionMessage(message)),
spawnWorkspaceScript,
assertAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(workspaceRegistry, workspaceId),
globalServicePorts: loadPersistedConfig(config.paseoHome).worktrees?.servicePorts,
}),
markWorkspaceArchiving: markWorkspaceArchivingExternal,
@@ -100,6 +100,17 @@ async function createPaseoWorktreeWithPriority(
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
expectsInitialAgent: Boolean(input.firstAgentContext),
...(createdWorktree.intent.kind === "checkout-change-request" &&
createdWorktree.intent.headRepository
? {
untrustedSource: {
kind: "change_request" as const,
forge: createdWorktree.intent.forge,
number: createdWorktree.intent.changeRequestNumber,
headRepository: createdWorktree.intent.headRepository,
},
}
: {}),
});
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
@@ -204,6 +204,7 @@ describe("resolveWorktreeCreationIntent", () => {
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
headRepositoryOwner: "therainisme",
headRepository: "therainisme/paseo",
localBranchName: "therainisme/main",
pushRemoteUrl: "git@github.com:therainisme/paseo.git",
});
@@ -285,6 +286,7 @@ describe("resolveWorktreeCreationIntent", () => {
forge: "gitlab",
changeRequestNumber: 7,
headRef: "feature/mr-source",
headRepository: "unknown repository",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/merge-requests/7/head" }],
});
@@ -187,12 +187,7 @@ async function resolvePrCheckoutIntent(
headRef,
}) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }];
const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget });
const headRepositoryOwner = checkoutTarget.isCrossRepository
? checkoutTarget.headOwnerLogin?.trim() || undefined
: undefined;
const pushRemoteUrl = checkoutTarget.isCrossRepository
? checkoutTarget.headRepositorySshUrl || checkoutTarget.headRepositoryUrl || undefined
: undefined;
const crossRepository = resolveCrossRepositoryFields(checkoutTarget);
const trackOriginHead = !checkoutTarget.isCrossRepository;
return {
@@ -200,15 +195,42 @@ async function resolvePrCheckoutIntent(
forge: deps.forge,
changeRequestNumber: params.changeRequestNumber,
headRef,
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
...crossRepository,
baseRefName,
checkoutRefs: checkoutTarget.checkoutRefs ?? defaultRefs,
...(localBranchName && localBranchName !== headRef ? { localBranchName } : {}),
...(pushRemoteUrl ? { pushRemoteUrl } : {}),
...(trackOriginHead ? { trackOriginHead } : {}),
};
}
function resolveCrossRepositoryFields(target: PullRequestCheckoutTarget): {
headRepositoryOwner?: string;
headRepository?: string;
pushRemoteUrl?: string;
} {
if (!target.isCrossRepository) return {};
const headRepositoryOwner = target.headOwnerLogin?.trim() || undefined;
const headRepository = resolveHeadRepository(target);
const pushRemoteUrl = target.headRepositorySshUrl || target.headRepositoryUrl || undefined;
return {
...(headRepositoryOwner ? { headRepositoryOwner } : {}),
...(headRepository ? { headRepository } : {}),
...(pushRemoteUrl ? { pushRemoteUrl } : {}),
};
}
function resolveHeadRepository(target: PullRequestCheckoutTarget): string | undefined {
const url = target.headRepositoryUrl ?? target.headRepositorySshUrl;
if (!url) return target.headOwnerLogin?.trim() || "unknown repository";
const path = url
.replace(/\.git$/, "")
.split(/[/:]/)
.filter(Boolean);
const repository = path.at(-1);
const owner = target.headOwnerLogin?.trim() || path.at(-2);
return (owner && repository ? `${owner}/${repository}` : repository) ?? "unknown repository";
}
function hasCheckoutRefs(target: PullRequestCheckoutTarget): boolean {
return Array.isArray(target.checkoutRefs) && target.checkoutRefs.length > 0;
}
+72 -5
View File
@@ -72,6 +72,11 @@ import {
normalizeClientRestartRpcReason,
} from "./lifecycle-reasons.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import {
assertWorkspaceAutomationAllowedForWorkspace,
clearWorkspaceAutomationBlock,
formatWorkspaceAutomationBlockedMessage,
} from "./workspace-automation-gate.js";
import {
WorkspaceLabelError,
WorkspaceLabelStorageUncertainError,
@@ -244,6 +249,7 @@ import {
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
handleWorkspaceSetupRunRequest as handleWorkspaceSetupRunRequestMessage,
} from "./worktree-session.js";
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
@@ -1053,6 +1059,8 @@ export class Session {
logger: this.sessionLogger,
emit: (message) => this.emit(message),
spawnWorkspaceScript,
assertAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
globalServicePorts: loadPersistedConfig(this.paseoHome).worktrees?.servicePorts,
});
this.subscribeToOptionalManagers();
@@ -1950,9 +1958,7 @@ export class Session {
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
this.dispatchWorkspaceRecoveryMessage(msg) ??
this.dispatchWorkspaceLabelMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchWorkspaceLifecycleMessage(msg) ??
this.dispatchWorkspaceFileMessage(msg, source) ??
this.dispatchProviderMessage(msg) ??
this.dispatchOrchestrationSkillsMessage(msg) ??
@@ -1964,6 +1970,15 @@ export class Session {
if (promise) await promise;
}
private dispatchWorkspaceLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
return (
this.dispatchWorkspaceRecoveryMessage(msg) ??
this.dispatchWorkspaceLabelMessage(msg) ??
this.dispatchWorkspaceSetupMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg)
);
}
private dispatchOrchestrationSkillsMessage(
msg: SessionInboundMessage,
): Promise<void> | undefined {
@@ -2476,8 +2491,6 @@ export class Session {
return this.handlePaseoWorktreeArchiveRequest(msg);
case "create_paseo_worktree_request":
return this.handleCreatePaseoWorktreeRequest(msg);
case "workspace_setup_status_request":
return this.handleWorkspaceSetupStatusRequest(msg);
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
case "list_available_editors_request":
return this.handleLegacyListAvailableEditorsRequest(msg);
@@ -2510,6 +2523,16 @@ export class Session {
}
}
private dispatchWorkspaceSetupMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "workspace_setup_status_request") {
return this.handleWorkspaceSetupStatusRequest(msg);
}
if (msg.type === "workspace.setup.run.request") {
return this.handleWorkspaceSetupRunRequest(msg);
}
return undefined;
}
private dispatchWorkspaceLabelMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "workspace.label.list.request":
@@ -6092,6 +6115,13 @@ export class Session {
requestId: request.requestId,
workspace: descriptor,
setupTerminalId: null,
...(result.workspace.untrustedSource
? {
setupSkippedReason: formatWorkspaceAutomationBlockedMessage(
result.workspace.untrustedSource,
),
}
: {}),
error: null,
},
});
@@ -6617,6 +6647,8 @@ export class Session {
}),
startWorkspaceSetup: (workspaceId, operation) =>
this.workspaceSetupRuntime.start(workspaceId, operation),
assertWorkspaceAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => {
@@ -6647,6 +6679,39 @@ export class Session {
{
emit: (message) => this.emit(message),
workspaceSetupSnapshots: this.workspaceSetupSnapshots,
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
},
request,
);
}
private async handleWorkspaceSetupRunRequest(
request: Extract<SessionInboundMessage, { type: "workspace.setup.run.request" }>,
): Promise<void> {
return handleWorkspaceSetupRunRequestMessage(
{
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
clearAutomationBlock: (workspaceId) =>
clearWorkspaceAutomationBlock(this.workspaceRegistry, workspaceId),
startWorkspaceSetup: (workspaceId, operation) =>
this.workspaceSetupRuntime.start(workspaceId, operation),
paseoHome: this.paseoHome,
worktreesRoot: this.worktreesRoot,
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) =>
this.workspaceSetupSnapshots.set(workspaceId, snapshot),
emit: (message) => this.emit(message),
sessionLogger: this.sessionLogger,
terminalManager: this.terminalManager,
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
serviceProxy: this.serviceProxy,
scriptRuntimeStore: this.scriptRuntimeStore,
getDaemonTcpPort: this.getDaemonTcpPort,
getDaemonTcpHost: this.getDaemonTcpHost,
serviceProxyPublicBaseUrl: this.serviceProxyPublicBaseUrl,
onScriptsChanged: (workspaceId, workspaceDirectory) =>
this.workspaceScripts.emitStatusUpdate(workspaceId, workspaceDirectory),
},
request,
);
@@ -6678,6 +6743,8 @@ export class Session {
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
this.markWorkspaceArchiving(workspaceIds, archivingAt),
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
assertWorkspaceAutomationAllowed: (workspaceId) =>
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
killTerminalsForWorkspace: (workspaceId) =>
this.terminalController.killTerminalsForWorkspace(workspaceId),
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
@@ -16,6 +16,7 @@ import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { deriveProjectKey } from "../../project-key.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js";
import type { UntrustedWorkspaceSource } from "../../workspace-automation-gate.js";
export interface ResolveOrCreateWorkspaceIdInput {
createdWorktree: CreatePaseoWorktreeWorkflowResult | null;
@@ -44,6 +45,7 @@ export interface CreateWorktreeWorkspaceInput {
baseBranch: string | null;
title: string | null;
expectsInitialAgent?: boolean;
untrustedSource?: UntrustedWorkspaceSource;
}
export interface WorkspaceProvisioningService {
@@ -244,6 +246,7 @@ export function createWorkspaceProvisioningService(deps: {
title: input.title,
createdAt: timestamp,
updatedAt: timestamp,
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
});
await workspaceRegistry.upsert(workspace, {
expectsInitialAgent: input.expectsInitialAgent,
@@ -77,6 +77,7 @@ interface BuildOptions {
project?: PersistedProjectRecord | null;
spawnThrows?: string;
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
automationError?: Error;
}
function buildService(options: BuildOptions = {}) {
@@ -120,6 +121,9 @@ function buildService(options: BuildOptions = {}) {
terminalId: "terminal-1",
};
},
assertAutomationAllowed: async () => {
if (options.automationError) throw options.automationError;
},
});
return { service, emitted, spawnCalls };
@@ -269,6 +273,28 @@ describe("stop", () => {
});
describe("start", () => {
test("refuses to start a script while repository automation is blocked", async () => {
const { service, emitted, spawnCalls } = buildService({
automationError: new Error(
"Scripts are blocked for PR #42 from contributor/paseo. Run setup to allow them.",
),
});
await service.start(request);
expect(spawnCalls).toEqual([]);
expect(emitted).toContainEqual({
type: "start_workspace_script_response",
payload: {
requestId: "req-1",
workspaceId: "ws-1",
scriptName: "app",
terminalId: null,
error: "Scripts are blocked for PR #42 from contributor/paseo. Run setup to allow them.",
},
});
});
test("reports an error when workspace scripts are unavailable", async () => {
const { service, emitted, spawnCalls } = buildService({ terminalManager: null });
await service.start(request);
@@ -67,6 +67,7 @@ export function createWorkspaceScriptsService(deps: {
logger: pino.Logger;
emit: (message: SessionOutboundMessage) => void;
spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise<WorktreeScriptResult>;
assertAutomationAllowed: (workspaceId: string) => Promise<void>;
}): WorkspaceScriptsService {
const {
serviceProxy,
@@ -83,6 +84,7 @@ export function createWorkspaceScriptsService(deps: {
logger,
emit,
spawnWorkspaceScript,
assertAutomationAllowed,
} = deps;
function resolveGitMetadata(
@@ -169,6 +171,7 @@ export function createWorkspaceScriptsService(deps: {
async function launchProcess(input: { workspaceId: string; scriptName: string }) {
const available = requireAvailable();
const workspace = await getWorkspace(input.workspaceId);
await assertAutomationAllowed(workspace.workspaceId);
const project = await projectRegistry.get(workspace.projectId);
const gitMetadata = resolveGitMetadata(workspace, project);
const result = await spawnWorkspaceScript({
@@ -1632,6 +1632,8 @@ export class VoiceAssistantWebSocketServer {
directorySync: true,
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
...(this.workspaceLabelService ? { workspaceLabels: true } : {}),
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
workspaceSetupRun: true,
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
@@ -18,6 +18,7 @@ import {
type ArchiveResult,
resolveWorkspaceIdAtPath,
} from "./workspace-archive-service.js";
import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js";
const cleanupPaths: string[] = [];
@@ -262,6 +263,56 @@ describe("archiveByScope", () => {
expect(readFileSync(path.join(repoDir, "shared-teardown.log"), "utf8")).toBe("ok");
});
test("workspace scope skips teardown while repository automation is blocked", async () => {
const { tempDir, repoDir } = createGitRepo();
const marker = path.join(repoDir, "blocked-teardown.log");
writeFileSync(
path.join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
teardown: [`node -e "require('fs').writeFileSync('${marker}', 'unsafe')"`],
},
}),
);
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "blocked teardown"], {
cwd: repoDir,
stdio: "pipe",
});
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "blocked-teardown");
const workspaceId = "ws-blocked-teardown";
const deps = createArchiveDeps({
paseoHome,
activeWorkspaces: [
{
workspaceId,
cwd: worktree.worktreePath,
kind: "worktree",
worktreeRoot: worktree.worktreePath,
isPaseoOwnedWorktree: true,
mainRepoRoot: repoDir,
},
],
});
deps.assertWorkspaceAutomationAllowed = async () => {
throw new WorkspaceAutomationBlockedError({
kind: "change_request",
forge: "github",
number: 42,
headRepository: "contributor/paseo",
});
};
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId },
requestId: "req-blocked-teardown",
});
expect(result.archivedWorkspaceIds).toEqual([workspaceId]);
expect(existsSync(marker)).toBe(false);
});
test("workspace scope keeps a worktree for an active workspace in a subdirectory", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
@@ -20,6 +20,7 @@ import type {
} from "./workspace-registry.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
import { runWithGitCommandPriority } from "../utils/run-git-command.js";
import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js";
export type ActiveWorkspaceRef = Pick<
PersistedWorkspaceRecord,
@@ -50,6 +51,7 @@ export interface ArchiveDependencies {
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
killTerminalsForWorkspace: (workspaceId: string) => Promise<void>;
stopWorkspaceSetup?: (workspaceId: string) => Promise<void>;
assertWorkspaceAutomationAllowed?: (workspaceId: string) => Promise<void>;
sessionLogger?: Logger;
}
@@ -357,21 +359,20 @@ async function maybeRemoveDirectory(
}
const archivedWorkspaceIdSet = new Set(archivedWorkspaceIds);
const teardownCwds = uniqueFilesystemPaths(
target.teardownTargets
.filter(
(teardownTarget) =>
teardownTarget.workspaceId === null ||
archivedWorkspaceIdSet.has(teardownTarget.workspaceId),
)
.map((teardownTarget) => teardownTarget.cwd),
const teardownTargets = target.teardownTargets.filter(
(teardownTarget) =>
teardownTarget.workspaceId === null || archivedWorkspaceIdSet.has(teardownTarget.workspaceId),
);
try {
for (const teardownCwd of teardownCwds) {
const allowedTeardownTargets = await filterAllowedTeardownTargets(
dependencies,
teardownTargets,
);
for (const teardownTarget of uniqueTeardownTargets(allowedTeardownTargets)) {
await runWorktreeTeardownCommands({
worktreePath: backing.path,
teardownCwd,
teardownCwd: teardownTarget.cwd,
repoRootPath: backing.mainRepoRoot ?? undefined,
});
}
@@ -418,10 +419,31 @@ async function maybeRemoveDirectory(
}
}
function uniqueFilesystemPaths(paths: string[]): string[] {
const unique: string[] = [];
for (const candidate of paths) {
if (!unique.some((existing) => createRealpathAwarePathMatcher(existing)(candidate))) {
async function filterAllowedTeardownTargets(
dependencies: ArchiveDependencies,
targets: Array<{ workspaceId: string | null; cwd: string }>,
): Promise<Array<{ workspaceId: string | null; cwd: string }>> {
const allowed: Array<{ workspaceId: string | null; cwd: string }> = [];
const blockedCwds: string[] = [];
for (const target of targets) {
try {
if (target.workspaceId)
await dependencies.assertWorkspaceAutomationAllowed?.(target.workspaceId);
allowed.push(target);
} catch (error) {
if (!(error instanceof WorkspaceAutomationBlockedError)) throw error;
blockedCwds.push(target.cwd);
}
}
return allowed.filter(
(target) => !blockedCwds.some((cwd) => createRealpathAwarePathMatcher(cwd)(target.cwd)),
);
}
function uniqueTeardownTargets<T extends { cwd: string }>(targets: T[]): T[] {
const unique: T[] = [];
for (const candidate of targets) {
if (!unique.some((existing) => createRealpathAwarePathMatcher(existing.cwd)(candidate.cwd))) {
unique.push(candidate);
}
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
WorkspaceAutomationBlockedError,
assertWorkspaceAutomationAllowed,
formatWorkspaceAutomationBlockedMessage,
} from "./workspace-automation-gate.js";
describe("workspace automation gate", () => {
it("refuses executable repository automation for a cross-repository change request", () => {
expect(() =>
assertWorkspaceAutomationAllowed({
kind: "change_request",
forge: "github",
number: 42,
headRepository: "contributor/paseo",
}),
).toThrowError(
new WorkspaceAutomationBlockedError({
kind: "change_request",
forge: "github",
number: 42,
headRepository: "contributor/paseo",
}),
);
});
it("allows executable repository automation for ordinary workspaces", () => {
expect(() => assertWorkspaceAutomationAllowed(undefined)).not.toThrow();
});
it.each([
["github", "PR"],
["gitea", "PR"],
["forgejo", "PR"],
["gitlab", "MR"],
])("uses the %s change request noun", (forge, noun) => {
expect(
formatWorkspaceAutomationBlockedMessage({
kind: "change_request",
forge,
number: 42,
headRepository: "contributor/paseo",
}),
).toBe(`Scripts are blocked for ${noun} #42 from contributor/paseo. Run setup to allow them.`);
});
});
@@ -0,0 +1,50 @@
import type { WorkspaceRegistry } from "./workspace-registry.js";
export interface UntrustedWorkspaceSource {
kind: "change_request";
forge: string;
number: number;
headRepository: string;
}
export function formatWorkspaceAutomationBlockedMessage(source: UntrustedWorkspaceSource): string {
const changeRequestNoun = source.forge === "gitlab" ? "MR" : "PR";
return `Scripts are blocked for ${changeRequestNoun} #${source.number} from ${source.headRepository}. Run setup to allow them.`;
}
export class WorkspaceAutomationBlockedError extends Error {
constructor(readonly source: UntrustedWorkspaceSource) {
super(formatWorkspaceAutomationBlockedMessage(source));
this.name = "WorkspaceAutomationBlockedError";
}
}
export function assertWorkspaceAutomationAllowed(
source: UntrustedWorkspaceSource | undefined,
): void {
if (source) throw new WorkspaceAutomationBlockedError(source);
}
export async function assertWorkspaceAutomationAllowedForWorkspace(
registry: Pick<WorkspaceRegistry, "get">,
workspaceId: string,
): Promise<void> {
const workspace = await registry.get(workspaceId);
if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
assertWorkspaceAutomationAllowed(workspace.untrustedSource);
}
export async function clearWorkspaceAutomationBlock(
registry: Pick<WorkspaceRegistry, "update">,
workspaceId: string,
): Promise<boolean> {
let cleared = false;
const workspace = await registry.update(workspaceId, (record) => {
if (!record.untrustedSource) return record;
cleared = true;
const { untrustedSource: _removed, ...trusted } = record;
return { ...trusted, updatedAt: new Date().toISOString() };
});
if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
return cleared;
}
@@ -10,6 +10,14 @@ import {
type PersistedProjectKind,
type PersistedWorkspaceKind,
} from "./workspace-registry-model.js";
import type { UntrustedWorkspaceSource } from "./workspace-automation-gate.js";
const UntrustedWorkspaceSourceSchema = z.object({
kind: z.literal("change_request"),
forge: z.string(),
number: z.number().int().positive(),
headRepository: z.string(),
});
const PersistedProjectRecordSchema = z.object({
projectId: z.string(),
@@ -93,6 +101,7 @@ const PersistedWorkspaceRecordSchema = z.object({
.optional()
.transform((value) => value ?? null),
labels: z.array(z.string()).optional(),
untrustedSource: UntrustedWorkspaceSourceSchema.optional(),
});
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
@@ -674,6 +683,7 @@ export function createPersistedWorkspaceRecord(input: {
autoArchivedChangeRequestUrl?: string | null;
pinnedAt?: string | null;
labels?: string[];
untrustedSource?: UntrustedWorkspaceSource;
}): PersistedWorkspaceRecord {
return PersistedWorkspaceRecordSchema.parse({
...input,
@@ -589,6 +589,31 @@ async function runWorktreeTerminalBootstrap(
);
}
export async function runWorktreeAutoTerminals(options: {
workspaceId: string;
worktree: WorktreeConfig;
workspaceCwd: string;
terminalManager: TerminalManager | null;
logger?: Logger;
}): Promise<void> {
const runtimeEnv = await resolveWorktreeRuntimeEnv({
worktreePath: options.worktree.worktreePath,
branchName: options.worktree.branchName,
});
await runWorktreeTerminalBootstrap(
{
agentId: options.workspaceId,
workspaceId: options.workspaceId,
worktree: options.worktree,
workspaceCwd: options.workspaceCwd,
terminalManager: options.terminalManager,
appendTimelineItem: async () => true,
logger: options.logger,
},
runtimeEnv,
);
}
export async function runAsyncWorktreeBootstrap(
options: RunAsyncWorktreeBootstrapOptions,
): Promise<void> {
@@ -1237,6 +1237,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
forge: "github",
changeRequestNumber: 526,
headRef: "main",
headRepository: "therainisme/therainisme",
headRepositoryOwner: "therainisme",
baseRefName: "main",
checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/526/head" }],
@@ -23,6 +23,7 @@ import {
runWorktreeSetupInBackground,
handleCreatePaseoWorktreeRequest,
handleWorkspaceSetupStatusRequest,
handleWorkspaceSetupRunRequest,
} from "./worktree-session.js";
import {
createWorktree as createWorktreePrimitive,
@@ -49,6 +50,7 @@ import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { isPlatform } from "../test-utils/platform.js";
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js";
interface LegacyCreateWorktreeTestOptions {
branchName: string;
@@ -451,6 +453,73 @@ describe("resolveGitCreateBaseBranch", () => {
});
describe("create-agent worktree setup boundary", () => {
test("blocked worktrees keep their workspace but skip setup and automatic terminals", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const setupMarker = path.join(tempDir, "setup-ran");
const emitted: SessionOutboundMessage[] = [];
writeFileSync(
path.join(repoDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [`node -e "require('fs').writeFileSync('${setupMarker}', 'ran')"`],
terminals: [{ command: "unsafe-terminal" }],
},
}),
);
try {
const result = await createPaseoWorktreeWorkflow(
{
paseoHome,
createPaseoWorktree: createPaseoWorktreeForTest({ paseoHome }),
warmWorkspaceGitData: async () => {},
autoNameWorkspaceBranchForFirstAgent: () => {},
assertWorkspaceAutomationAllowed: async () => {
throw new WorkspaceAutomationBlockedError({
kind: "change_request",
forge: "github",
number: 42,
headRepository: "contributor/paseo",
});
},
emitWorkspaceUpdateForWorkspaceId: async () => {},
cacheWorkspaceSetupSnapshot: () => {},
emit: (message) => emitted.push(message),
sessionLogger: createLogger(),
terminalManager: createTerminalManagerStub().manager,
archiveWorkspaceRecord: async () => {},
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
onScriptsChanged: null,
},
{ cwd: repoDir, worktreeSlug: "blocked-fork", runSetup: false, paseoHome },
{
setupContinuation: {
kind: "agent",
terminalManager: createTerminalManagerStub().manager,
appendTimelineItem: async () => true,
logger: createLogger(),
},
},
);
expect(result.setupContinuation?.kind).toBe("agent");
result.setupContinuation?.startAfterAgentCreate({ agentId: "agent-fork" });
expect(existsSync(setupMarker)).toBe(false);
expect(emitted).toContainEqual(
expect.objectContaining({
type: "workspace_setup_progress",
payload: expect.objectContaining({ status: "blocked" }),
}),
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("agent setup continuation starts setup for the created agent timeline", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
@@ -1222,6 +1291,7 @@ describe("runWorktreeSetupInBackground", () => {
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: snapshots,
getWorkspace: async () => null,
},
{
type: "workspace_setup_status_request",
@@ -1257,6 +1327,7 @@ describe("runWorktreeSetupInBackground", () => {
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: new Map(),
getWorkspace: async () => null,
},
{
type: "workspace_setup_status_request",
@@ -1274,6 +1345,121 @@ describe("runWorktreeSetupInBackground", () => {
},
});
});
test("reports persisted fork provenance as blocked after restart", async () => {
const emitted: SessionOutboundMessage[] = [];
await handleWorkspaceSetupStatusRequest(
{
emit: (message) => emitted.push(message),
workspaceSetupSnapshots: new Map(),
getWorkspace: async () =>
({
workspaceId: "ws-fork",
cwd: "/repo/fork",
worktreeRoot: "/repo/fork",
branch: "fork-branch",
untrustedSource: {
kind: "change_request",
forge: "github",
number: 42,
headRepository: "contributor/paseo",
},
}) as PersistedWorkspaceRecord,
},
{
type: "workspace_setup_status_request",
workspaceId: "ws-fork",
requestId: "req-blocked",
},
);
expect(emitted[0]).toMatchObject({
type: "workspace_setup_status_response",
payload: {
snapshot: {
status: "blocked",
blockedSource: { number: 42, headRepository: "contributor/paseo" },
},
},
});
});
test("run setup clears the block and starts the existing setup runtime once", async () => {
const tempDir = mkdtempSync(path.join(tmpdir(), "workspace-setup-run-"));
cleanupPaths.push(tempDir);
execFileSync("git", ["init", "-b", "fork-branch"], { cwd: tempDir, stdio: "ignore" });
const setupMarker = path.join(tempDir, "setup-ran");
writeFileSync(
path.join(tempDir, "paseo.json"),
JSON.stringify({
worktree: {
setup: [`node -e "require('fs').writeFileSync('setup-ran', 'ran')"`],
terminals: [{ command: "start-preview" }],
},
}),
);
const emitted: SessionOutboundMessage[] = [];
let blocked = true;
const operations: Array<(signal: AbortSignal) => Promise<void>> = [];
const workspace = {
workspaceId: "ws-fork",
cwd: tempDir,
worktreeRoot: tempDir,
branch: "fork-branch",
mainRepoRoot: tempDir,
archivedAt: null,
} as PersistedWorkspaceRecord;
const terminalManager = createTerminalManagerStub();
const dependencies = {
getWorkspace: async () => workspace,
clearAutomationBlock: async () => {
if (!blocked) return false;
blocked = false;
return true;
},
startWorkspaceSetup: (
_workspaceId: string,
operation: (signal: AbortSignal) => Promise<void>,
) => operations.push(operation),
emitWorkspaceUpdateForWorkspaceId: vi.fn(async () => {}),
cacheWorkspaceSetupSnapshot: vi.fn(),
emit: (message: SessionOutboundMessage) => emitted.push(message),
sessionLogger: createLogger(),
terminalManager: terminalManager.manager,
archiveWorkspaceRecord: vi.fn(async () => {}),
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
onScriptsChanged: null,
};
await handleWorkspaceSetupRunRequest(dependencies, {
type: "workspace.setup.run.request",
workspaceId: "ws-fork",
requestId: "req-run-1",
});
await handleWorkspaceSetupRunRequest(dependencies, {
type: "workspace.setup.run.request",
workspaceId: "ws-fork",
requestId: "req-run-2",
});
expect(operations).toHaveLength(1);
await operations[0]!(new AbortController().signal);
expect(readFileSync(setupMarker, "utf8")).toBe("ran");
expect(terminalManager.terminals[0]?.sent).toEqual(["start-preview\r"]);
expect(emitted.filter((message) => message.type === "workspace.setup.run.response")).toEqual([
{
type: "workspace.setup.run.response",
payload: { requestId: "req-run-1", workspaceId: "ws-fork", started: true, error: null },
},
{
type: "workspace.setup.run.response",
payload: { requestId: "req-run-2", workspaceId: "ws-fork", started: false, error: null },
},
]);
});
});
describe("handleCreatePaseoWorktreeRequest", () => {
+131 -1
View File
@@ -15,6 +15,7 @@ import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
runAsyncWorktreeBootstrap,
runWorktreeAutoTerminals,
applyWorktreeSetupProgressEvent,
buildWorktreeSetupDetail,
createWorktreeSetupProgressAccumulator,
@@ -48,6 +49,10 @@ import {
listPaseoWorktreesCommand,
} from "./worktree/commands.js";
import type { WorkspaceSetupOperation } from "./workspace-setup-runtime.js";
import {
formatWorkspaceAutomationBlockedMessage,
WorkspaceAutomationBlockedError,
} from "./workspace-automation-gate.js";
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
@@ -125,6 +130,7 @@ interface CreatePaseoWorktreeWorkflowDependencies extends CreatePaseoWorktreeInB
firstAgentContext: FirstAgentContext;
}) => void;
startWorkspaceSetup?: (workspaceId: string, operation: WorkspaceSetupOperation) => void;
assertWorkspaceAutomationAllowed?: (workspaceId: string) => Promise<void>;
}
interface AgentWorktreeSetupContinuationInput {
@@ -159,6 +165,13 @@ export type CreatePaseoWorktreeWorkflowFn = (
interface HandleWorkspaceSetupStatusRequestDependencies {
emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
}
interface HandleWorkspaceSetupRunRequestDependencies extends CreatePaseoWorktreeInBackgroundDependencies {
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
clearAutomationBlock: (workspaceId: string) => Promise<boolean>;
startWorkspaceSetup: (workspaceId: string, operation: WorkspaceSetupOperation) => void;
}
interface HandleCreatePaseoWorktreeRequestDependencies {
@@ -556,6 +569,13 @@ export async function handleCreatePaseoWorktreeRequest(
workspace: descriptor,
error: null,
setupTerminalId: null,
...(createdWorktree.workspace.untrustedSource
? {
setupSkippedReason: formatWorkspaceAutomationBlockedMessage(
createdWorktree.workspace.untrustedSource,
),
}
: {}),
requestId: request.requestId,
},
});
@@ -608,6 +628,33 @@ export async function createPaseoWorktreeWorkflow(
const workspace = createdWorktree.workspace;
const setupContinuation = options?.setupContinuation ?? { kind: "workspace" };
try {
await dependencies.assertWorkspaceAutomationAllowed?.(workspace.workspaceId);
} catch (error) {
if (!(error instanceof WorkspaceAutomationBlockedError)) throw error;
const snapshot: WorkspaceSetupSnapshot = {
status: "blocked",
detail: buildWorktreeSetupDetail({ worktree: createdWorktree.worktree, results: [] }),
error: null,
blockedSource: error.source,
};
dependencies.cacheWorkspaceSetupSnapshot(workspace.workspaceId, snapshot);
dependencies.emit({
type: "workspace_setup_progress",
payload: { workspaceId: workspace.workspaceId, ...snapshot },
});
if (setupContinuation.kind === "agent") {
return {
...createdWorktree,
setupContinuation: {
kind: "agent",
startAfterAgentCreate: () => undefined,
},
};
}
return createdWorktree;
}
setTimeout(() => {
if (input.firstAgentContext) {
dependencies.autoNameWorkspaceBranchForFirstAgent({
@@ -676,7 +723,24 @@ export async function handleWorkspaceSetupStatusRequest(
request: Extract<SessionInboundMessage, { type: "workspace_setup_status_request" }>,
): Promise<void> {
const workspaceId = request.workspaceId;
const snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null;
let snapshot = dependencies.workspaceSetupSnapshots.get(workspaceId) ?? null;
if (!snapshot) {
const workspace = await dependencies.getWorkspace(workspaceId);
if (workspace?.untrustedSource) {
snapshot = {
status: "blocked",
detail: buildWorktreeSetupDetail({
worktree: {
worktreePath: workspace.worktreeRoot ?? workspace.cwd,
branchName: workspace.branch ?? "",
},
results: [],
}),
error: null,
blockedSource: workspace.untrustedSource,
};
}
}
dependencies.emit({
type: "workspace_setup_status_response",
@@ -688,6 +752,62 @@ export async function handleWorkspaceSetupStatusRequest(
});
}
export async function handleWorkspaceSetupRunRequest(
dependencies: HandleWorkspaceSetupRunRequestDependencies,
request: Extract<SessionInboundMessage, { type: "workspace.setup.run.request" }>,
): Promise<void> {
try {
const workspace = await dependencies.getWorkspace(request.workspaceId);
if (!workspace || workspace.archivedAt) {
throw new Error(`Workspace not found: ${request.workspaceId}`);
}
const started = await dependencies.clearAutomationBlock(request.workspaceId);
if (started) {
const worktree: WorktreeConfig = {
worktreePath: workspace.worktreeRoot ?? workspace.cwd,
branchName: workspace.branch ?? "",
};
dependencies.startWorkspaceSetup(request.workspaceId, (signal) =>
runWorktreeSetupInBackground(
dependencies,
{
requestCwd: workspace.cwd,
repoRoot: workspace.mainRepoRoot ?? workspace.cwd,
workspaceId: workspace.workspaceId,
worktree,
shouldBootstrap: true,
slug: basename(worktree.worktreePath),
worktreePath: worktree.worktreePath,
workspaceCwd: workspace.cwd,
runAutoTerminals: true,
},
signal,
),
);
await dependencies.emitWorkspaceUpdateForWorkspaceId(request.workspaceId);
}
dependencies.emit({
type: "workspace.setup.run.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
started,
error: null,
},
});
} catch (error) {
dependencies.emit({
type: "workspace.setup.run.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
started: false,
error: error instanceof Error ? error.message : "Failed to run workspace setup",
},
});
}
}
export async function runWorktreeSetupInBackground(
dependencies: CreatePaseoWorktreeInBackgroundDependencies,
options: {
@@ -699,6 +819,7 @@ export async function runWorktreeSetupInBackground(
slug: string;
worktreePath: string;
workspaceCwd?: string;
runAutoTerminals?: boolean;
},
signal?: AbortSignal,
): Promise<void> {
@@ -768,6 +889,15 @@ export async function runWorktreeSetupInBackground(
});
emitSetupProgress("completed", null);
}
if (options.runAutoTerminals) {
await runWorktreeAutoTerminals({
workspaceId,
worktree,
workspaceCwd,
terminalManager: dependencies.terminalManager,
logger: dependencies.sessionLogger,
});
}
}
} catch (error) {
if (error instanceof WorktreeSetupError) {
+1
View File
@@ -186,6 +186,7 @@ export type WorktreeSource =
changeRequestNumber: number;
headRef: string;
headRepositoryOwner?: string;
headRepository?: string;
baseRefName: string;
checkoutRefs?: WorktreeCheckoutRef[];
localBranchName?: string;