diff --git a/SECURITY.md b/SECURITY.md index d97910122e..1c95ca9a78 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 diff --git a/docs/data-model.md b/docs/data-model.md index 1dce303489..291c58f736 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -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_`), 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_`), 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. diff --git a/packages/app/e2e/browser/fork-workspace-setup-gate.spec.ts b/packages/app/e2e/browser/fork-workspace-setup-gate.spec.ts new file mode 100644 index 0000000000..d717e8a915 --- /dev/null +++ b/packages/app/e2e/browser/fork-workspace-setup-gate.spec.ts @@ -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(); + } +}); diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index d373224fd8..1f1cf783a2 100644 --- a/packages/app/e2e/support/helpers/e2e-worker.ts +++ b/packages/app/e2e/support/helpers/e2e-worker.ts @@ -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 } } } } })); diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index 2b5dddc05e..bfe6b2c8f5 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -38,13 +38,9 @@ function itemLabel(key: SidebarNavKey): string { }[key]; } -async function rowTop(locator: Locator): Promise { - await locator.waitFor({ state: "visible" }); +async function rowTop(locator: Locator): Promise { 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 }, diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 14ee1b6569..9466bc6ba6 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -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 = ( - - {icon ? {icon} : null} - {typeof toast.content === "string" ? ( - - {toast.content} - - ) : ( - - {toast.content} - - )} - + + + {icon ? {icon} : null} + {typeof toast.content === "string" ? ( + + {toast.content} + + ) : ( + + {toast.content} + + )} + + ); @@ -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], diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f9e53d2be7..e09ef311de 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "لم يتم تشغيل أي أوامر إعداد لمساحة العمل هذه.", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 9f41bc2af8..2516a3f064 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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.", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index e9d03a9029..1b7ee5118a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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.", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index d329d5e59c..5807c7e86f 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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.", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 91d6fa0ae1..1e2e95838d 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "このワークスペースでセットアップコマンドは実行されませんでした。", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 92e56de1ef..a37feac7a3 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -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: "이 워크스페이스에서 실행된 설정 명령이 없습니다.", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index e1d74a58c2..f6ee9bdc29 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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.", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index cacf8a54c0..eefd8aa147 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Для этого рабочего пространства не выполнялись команды настройки.", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f02684b49e..35e160de59 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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 命令。", }, diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 8b6057c06d..9934bec864 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -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; +}) { + const { t } = useTranslation(); + const client = useHostRuntimeClient(serverId); + const supportsSetupRun = useHostFeature(serverId, "workspaceSetupRun"); + const [runError, setRunError] = useState(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 ( + <> + + {supportsSetupRun ? ( + + ) : null} + + {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, + labels: Record, ): 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>(new Set()); const [manuallyCollapsed, setManuallyCollapsed] = useState>(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() { {statusLabel} - - {isWaiting ? ( - - - {t("workspace.setup.waiting")} - - ) : null} - {!isWaiting && hasNoSetupCommands ? ( - - - {t("workspace.setup.empty.noCommands")} - - - ) : null} - {!isWaiting && !hasNoSetupCommands ? ( - - {commands.map((command) => { - const rowState = buildCommandRowState({ - command, - autoExpandIndex, - log, - expandedIndices, - manuallyCollapsed, - snapshotError: snapshot?.error, - }); - - return ( - - ); - })} - - - - - ) : null} + ); } +interface SetupPanelBodyProps { + serverId: string; + workspaceId: string; + snapshot: WorkspaceSetupSnapshot | null; + commands: SetupCommand[]; + log: string; + isWaiting: boolean; + hasNoSetupCommands: boolean; + autoExpandIndex: number | null; + expandedIndices: Set; + manuallyCollapsed: Set; + 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 ( + + ); + } + if (props.isWaiting) { + return ( + + + {t("workspace.setup.waiting")} + + ); + } + if (props.hasNoSetupCommands) { + return ( + + + {t("workspace.setup.empty.noCommands")} + + + ); + } + return ( + + {commands.map((command) => { + const rowState = buildCommandRowState({ + command, + autoExpandIndex: props.autoExpandIndex, + log, + expandedIndices: props.expandedIndices, + manuallyCollapsed: props.manuallyCollapsed, + snapshotError: snapshot?.error, + }); + return ( + + ); + })} + + + + ); +} + interface SetupCommandRowProps { command: SetupCommand; showDetail: boolean; diff --git a/packages/app/src/stores/workspace-setup-store.test.ts b/packages/app/src/stores/workspace-setup-store.test.ts index 2f590cceb6..f826d8ffa0 100644 --- a/packages/app/src/stores/workspace-setup-store.test.ts +++ b/packages/app/src/stores/workspace-setup-store.test.ts @@ -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", () => { diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index bc5cace523..31e3bfc024 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -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()((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()((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; diff --git a/packages/cli/src/commands/agent/run.ts b/packages/cli/src/commands/agent/run.ts index 14e494c569..a642909952 100644 --- a/packages/cli/src/commands/agent/run.ts +++ b/packages/cli/src/commands/agent/run.ts @@ -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 (or set PASEO_WORKSPACE_ID) to run in an existing workspace.", ); diff --git a/packages/cli/src/commands/workspace/index.ts b/packages/cli/src/commands/workspace/index.ts index bf143fa137..19d640d252 100644 --- a/packages/cli/src/commands/workspace/index.ts +++ b/packages/cli/src/commands/workspace/index.ts @@ -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"), + ).action(withOutput(runSetupCommand)); + addJsonAndDaemonHostOptions( workspace .command("create") diff --git a/packages/cli/src/commands/workspace/setup.ts b/packages/cli/src/commands/workspace/setup.ts new file mode 100644 index 0000000000..ce15d10f26 --- /dev/null +++ b/packages/cli/src/commands/workspace/setup.ts @@ -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 = { + 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> { + 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); + } +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 05da6177ad..62bf674b5f 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2392,6 +2392,16 @@ export class DaemonClient { }); } + async runWorkspaceSetup( + workspaceId: string, + requestId?: string, + ): Promise["payload"]> { + return this.sendNamespacedCorrelatedSessionRequest<"workspace.setup.run.response">({ + requestId, + message: { type: "workspace.setup.run.request", workspaceId }, + }); + } + async fetchAgent(options: FetchAgentOptions): Promise; async fetchAgent(agentId: string, requestId?: string): Promise; async fetchAgent( diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f8c50b2d82..954b8acad7 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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; +export type WorkspaceSetupRunResponseMessage = z.infer< + typeof WorkspaceSetupRunResponseMessageSchema +>; export type AgentStreamMessage = z.infer; export type AgentStatusMessage = z.infer; export type ProjectCheckoutLitePayload = z.infer; @@ -6913,6 +6954,7 @@ export type PaseoWorktreeListResponse = z.infer; export type PaseoWorktreeArchiveResponse = z.infer; export type WorkspaceSetupStatusRequest = z.infer; +export type WorkspaceSetupRunRequest = z.infer; export type LegacyListAvailableEditorsRequest = z.infer< typeof LegacyListAvailableEditorsRequestSchema >; diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index a856f3674e..a9e8f87c9a 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -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", diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 9fcafecd79..ae5ab24544 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -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; @@ -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; diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 8188b7062f..627a93d4e3 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -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, diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index b32123cb37..dea17e1bb9 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -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 }); diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index c2f738fc95..82895e302b 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -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" }], }); diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index aa9409a5c8..1d6ae13677 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -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; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0ab2c0eea9..bb07240014 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 | undefined { + return ( + this.dispatchWorkspaceRecoveryMessage(msg) ?? + this.dispatchWorkspaceLabelMessage(msg) ?? + this.dispatchWorkspaceSetupMessage(msg) ?? + this.dispatchWorkspaceAndProjectMessage(msg) + ); + } + private dispatchOrchestrationSkillsMessage( msg: SessionInboundMessage, ): Promise | 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 | 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 | 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, + ): Promise { + 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), diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 367974edc4..eb9905016d 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -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, diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts index c377639747..0ba8a0169b 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.test.ts @@ -77,6 +77,7 @@ interface BuildOptions { project?: PersistedProjectRecord | null; spawnThrows?: string; gitService?: Pick; + 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); diff --git a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts index b43e13548b..7dee9364f9 100644 --- a/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts +++ b/packages/server/src/server/session/workspace-scripts/workspace-scripts-service.ts @@ -67,6 +67,7 @@ export function createWorkspaceScriptsService(deps: { logger: pino.Logger; emit: (message: SessionOutboundMessage) => void; spawnWorkspaceScript: (options: SpawnWorkspaceScriptOptions) => Promise; + assertAutomationAllowed: (workspaceId: string) => Promise; }): 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({ diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index d348792cd7..8cf4af4e2e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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. diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index ac70ecbefb..c3b77f7070 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -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"); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 8b37cae3f1..a02c97eda8 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -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) => void; killTerminalsForWorkspace: (workspaceId: string) => Promise; stopWorkspaceSetup?: (workspaceId: string) => Promise; + assertWorkspaceAutomationAllowed?: (workspaceId: string) => Promise; 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> { + 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(targets: T[]): T[] { + const unique: T[] = []; + for (const candidate of targets) { + if (!unique.some((existing) => createRealpathAwarePathMatcher(existing.cwd)(candidate.cwd))) { unique.push(candidate); } } diff --git a/packages/server/src/server/workspace-automation-gate.test.ts b/packages/server/src/server/workspace-automation-gate.test.ts new file mode 100644 index 0000000000..a3d49345f3 --- /dev/null +++ b/packages/server/src/server/workspace-automation-gate.test.ts @@ -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.`); + }); +}); diff --git a/packages/server/src/server/workspace-automation-gate.ts b/packages/server/src/server/workspace-automation-gate.ts new file mode 100644 index 0000000000..652903ae6f --- /dev/null +++ b/packages/server/src/server/workspace-automation-gate.ts @@ -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, + workspaceId: string, +): Promise { + const workspace = await registry.get(workspaceId); + if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`); + assertWorkspaceAutomationAllowed(workspace.untrustedSource); +} + +export async function clearWorkspaceAutomationBlock( + registry: Pick, + workspaceId: string, +): Promise { + 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; +} diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index bbfef53a30..b4b8d5cdd5 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -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; @@ -674,6 +683,7 @@ export function createPersistedWorkspaceRecord(input: { autoArchivedChangeRequestUrl?: string | null; pinnedAt?: string | null; labels?: string[]; + untrustedSource?: UntrustedWorkspaceSource; }): PersistedWorkspaceRecord { return PersistedWorkspaceRecordSchema.parse({ ...input, diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 75785057e4..8e69908f59 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -589,6 +589,31 @@ async function runWorktreeTerminalBootstrap( ); } +export async function runWorktreeAutoTerminals(options: { + workspaceId: string; + worktree: WorktreeConfig; + workspaceCwd: string; + terminalManager: TerminalManager | null; + logger?: Logger; +}): Promise { + 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 { diff --git a/packages/server/src/server/worktree-core.posix.test.ts b/packages/server/src/server/worktree-core.posix.test.ts index 527b989d7e..d5f85ae364 100644 --- a/packages/server/src/server/worktree-core.posix.test.ts +++ b/packages/server/src/server/worktree-core.posix.test.ts @@ -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" }], diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index a5de5baedb..384a74a54d 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -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> = []; + 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, + ) => 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", () => { diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 0dffe569e3..c693bc5380 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -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; } interface AgentWorktreeSetupContinuationInput { @@ -159,6 +165,13 @@ export type CreatePaseoWorktreeWorkflowFn = ( interface HandleWorkspaceSetupStatusRequestDependencies { emit: EmitSessionMessage; workspaceSetupSnapshots: ReadonlyMap; + getWorkspace: (workspaceId: string) => Promise; +} + +interface HandleWorkspaceSetupRunRequestDependencies extends CreatePaseoWorktreeInBackgroundDependencies { + getWorkspace: (workspaceId: string) => Promise; + clearAutomationBlock: (workspaceId: string) => Promise; + 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, ): Promise { 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, +): Promise { + 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 { @@ -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) { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index c1dd348217..987867c0d3 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -186,6 +186,7 @@ export type WorktreeSource = changeRequestNumber: number; headRef: string; headRepositoryOwner?: string; + headRepository?: string; baseRefName: string; checkoutRefs?: WorktreeCheckoutRef[]; localBranchName?: string;