From 24067a16b5912aee7c33b208524d7a28639cca26 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:03:16 -0500 Subject: [PATCH] fix(workspaces): classify restricted shared repository admission (#16895) ## Thinking Path > - Paperclip manages agents, their tasks, and their saved work. > - Workspace preparation must check access before an agent starts. > - A shared project checkout can contain another task's private worktree. > - The access guard correctly rejects a run that cannot read that work. > - The isolated-source path reports this as a configuration blocker after #16545. > - Shared project repository admission missed that classification and returned a generic setup failure. > - This change uses the same blocker at that boundary so recovery names the required workspace repair. ## Linked Issues or Issue Description Refs #16545 and #16095. The open retained-reader repair in #16832 addresses deleted identities, a separate condition. **What happened?** A task reused a shared project workspace. Another worktree in that checkout retained a private task that the run could not read. Repository admission rejected access before provider startup. The run reported `setup_failed`, and recovery showed a generic no-live-execution notice. **Expected behavior** Keep the access denial. Report `configuration_incomplete` with reason `workspace_source_restricted`. Preserve the workspace and files. Give the owner the existing workspace repair action. Do not queue an automatic failure retry or report this expected blocker as an unexpected Sentry failure. **Steps to reproduce** 1. Bind a task to a shared project checkout. 2. Add a private task worktree beneath that checkout. 3. Continue the shared task as an agent that cannot read the private task. 4. Inspect the run result and recovery action. **Version and deployment** Reproduced in the full heartbeat service from source revision `06d4fe3121`, with PostgreSQL and real Git worktrees on macOS. The provider never starts. The regression also covers the existing Git-worktree path. ## What Changed - Apply the existing restricted-source classifier to task repository admission. - Extend the heartbeat regression to shared project workspaces. - Assert one run and one recovery action, no provider start, no unexpected Sentry report, and preserved binding and files. - Update the workspace documentation and both capability maps. ## Verification - Red: the shared project regression returned `setup_failed`; the Git-worktree control passed. - Green: the same two cases pass after the change. - `node scripts/check-maps.mjs` and `git diff --check` passed. - Current head `e26cdd45da76c6edaa1622856f9c69e9f48b81fb`: 55 successful checks, one neutral check, two skipped Storybook checks. Greptile 5/5, no review threads, mergeable. - All 337 tests passed across `heartbeat-workspace-branch-containment`, `heartbeat/workspace-preparation`, `run-failure-report`, and `recovery/stranded-notice`. - `pnpm -r typecheck` and `pnpm build` passed. All full current-head CI suites passed in [run 39315969013](https://github.com/paperclipai/paperclip/actions/runs/39315969013). The duplicate local full Vitest run was stopped after 11 minutes when CI completed; it has no local full-suite completion claim. The 337 affected local tests completed successfully. - Running-app acceptance at `e26cdd45da76c6edaa1622856f9c69e9f48b81fb`: a synthetic private Git worktree caused the shared task to stop with the visible Workspace source restricted notice and no process start. The normal workspace API selected a separate task directory. Resolving the recovery action resumed the same assignee. A real process fixture completed, the output persisted after reload, and both source files remained intact. Checkout and execution locks cleared. There were no automatic failure retries. - The first recovered process wrote its report to the command cwd. This was a fixture output-path mistake. The corrected fixture reads the selected workspace from the authenticated API before writing. One explicit additional admission verified that output. The record retains both successful process runs and the earlier configuration-blocked run. This is local process-adapter acceptance, not live Codex/provider qualification. - Staging diagnosis confirmed a retained private source and failure before provider startup. The original task has not been recovered. The deployed UI hides its workspace selector, and no authenticated task-write API is available in this session. No live Codex recovery is claimed. ## Risks The same exact 403 denial is now a configuration blocker during shared repository admission. Other failures retain their original type. Access policy, cloning, credentials, provider execution, and workspace contents do not change. An existing task still requires a permitted workspace repair before it can run. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code execution, and browser tools. The exact deployment identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- doc/project-repositories.md | 3 +++ maps/agent-access-map/workspaces.md | 3 +++ maps/feature-map/workspaces.md | 6 ++++++ .../heartbeat-workspace-branch-containment.test.ts | 12 +++++++++++- .../src/services/heartbeat/workspace-preparation.ts | 12 +++++++++--- 5 files changed, 32 insertions(+), 4 deletions(-) diff --git a/doc/project-repositories.md b/doc/project-repositories.md index 18aef98676c..e6eee599566 100644 --- a/doc/project-repositories.md +++ b/doc/project-repositories.md @@ -56,6 +56,9 @@ When the denial is retained, the run stops before the agent starts with a is blocked with a notice that tells the owner to push unpublished work and select a new workspace, or to grant access to the restricted task, then retry. Paperclip does not report this owner action to Sentry as an unexpected run failure. +This applies to both isolated-source checks and repository admission for a shared +project workspace. Neither path replaces the existing workspace or retries the +same access denial automatically. Startup validation of these recorded defaults keeps the task's effective workspace strategy. It does not substitute the agent's shared strategy or grant project writes. Workspace close and reopen use the recorded isolated source. Both operations diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md index 105f2992f1b..280187e393e 100644 --- a/maps/agent-access-map/workspaces.md +++ b/maps/agent-access-map/workspaces.md @@ -66,6 +66,9 @@ the current company-scoped binding before considering the fallback. A retained denial blocks the task as `configuration_incomplete` (`workspace_source_restricted`) before dispatch; it grants no access and names the owner action on the task. +The same blocker applies when shared project workspace repository admission finds +the restricted source. The existing binding and files stay intact; no provider +starts and no automatic failure retry is queued. Local paths, unsupported transports and credential-bearing URLs are not fallback sources. Company/task/remote bindings, real paths, Git storage and the exact origin are revalidated for reuse, cleanup and reopening. A failed save removes only the diff --git a/maps/feature-map/workspaces.md b/maps/feature-map/workspaces.md index 7709d432293..348375eb6d3 100644 --- a/maps/feature-map/workspaces.md +++ b/maps/feature-map/workspaces.md @@ -74,6 +74,12 @@ and [preparation tests](../../server/src/services/heartbeat/workspace-preparatio exercise the real Git isolation and lifecycle guards. They do not qualify live remote credentials or a provider deployment. +For a task reusing the shared project checkout, add a private task worktree that +the assigned agent cannot read. Continue the shared task and verify the +**Workspace source restricted** notice names a workspace repair action. No agent +turn or automatic failure retry may start. Preserve the task binding and both +tasks' files. Repeat with a reused Git worktree to check the same blocker. + For reconstructable Git expiration, finish a disposable task tree, run the internal lifecycle sweep after its 24-hour grace, and inspect candidate/reclaimed bytes. Advance its upstream first; use the ordinary task/workspace reopen control diff --git a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts index e37a2af9b81..bf3931046ee 100644 --- a/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts +++ b/server/src/__tests__/heartbeat-workspace-branch-containment.test.ts @@ -1075,9 +1075,17 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { }, ); - it("blocks a reused shared-checkout workspace with the owner action once a restricted task worktree shares it", async () => { + it.each(["git_worktree", "project_primary"] as const)("blocks a reused %s workspace with the owner action once a restricted task worktree shares it", async (strategyType) => { vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected"); const seeded = await seedRetainedSourceRun("deleted"); + if (strategyType === "project_primary") { + await db.update(projectWorkspaces).set({ cwd: seeded.owner, repoUrl: seeded.repoUrl }).where(eq(projectWorkspaces.id, seeded.newWorkspaceId)); + await db.update(executionWorkspaces).set({ mode: "shared_workspace", strategyType, + projectWorkspaceId: seeded.newWorkspaceId, cwd: seeded.owner, providerRef: null, + providerType: "local_fs", branchName: null, metadata: null }).where(eq(executionWorkspaces.id, seeded.executionWorkspaceId)); + await db.update(issues).set({ projectWorkspaceId: seeded.newWorkspaceId, + executionWorkspaceSettings: { mode: "shared_workspace" } }).where(eq(issues.id, seeded.issueId)); + } const [privateIssue] = await db.insert(issues).values({ companyId: seeded.companyId, projectId: seeded.projectId, title: "Private work", visibility: "private" }).returning(); const privateCwd = path.join(seeded.owner, ".paperclip", "worktrees", "restricted-task"); @@ -1104,7 +1112,9 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => { expect(comments.map(comment => comment.body).join("\n")).toContain("shared project checkout that also holds another task's workspace"); const actions = await db.select({ nextAction: issueRecoveryActions.nextAction }).from(issueRecoveryActions) .where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId)); + expect(actions).toHaveLength(1); expect(actions.map(action => action.nextAction).join("\n")).toContain("select a new workspace"); + expect(await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(eq(heartbeatRuns.companyId, seeded.companyId))).toHaveLength(1); expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n"); }); diff --git a/server/src/services/heartbeat/workspace-preparation.ts b/server/src/services/heartbeat/workspace-preparation.ts index 0dd58f85709..c5c941160da 100644 --- a/server/src/services/heartbeat/workspace-preparation.ts +++ b/server/src/services/heartbeat/workspace-preparation.ts @@ -975,9 +975,15 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace); const projectRepositoryPaths: string[] = []; if (!isDotRun && persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) { - const taskRepositories = await timeWorkspace("workspace_repository_admission", () => executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId, - issueId, workspaceId: persistedExecutionWorkspace!.id, cwd: executionWorkspace.cwd, - agentId: agent.id, runId: run.id, responsibleUserId })); + const taskRepositories = await timeWorkspace("workspace_repository_admission", async () => { + try { + return await executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId, + issueId, workspaceId: persistedExecutionWorkspace!.id, cwd: executionWorkspace.cwd, + agentId: agent.id, runId: run.id, responsibleUserId }); + } catch (error) { + throw restrictedWorkspaceSourceFailure(error, issueId); + } + }); projectRepositoryPaths.push(...taskRepositories.map(repository => repository.relativePath)); } if (executionWorkspace.projectId