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