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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-19 16:03:16 -05:00
1 parent ac9459bae3
commit 24067a16b5
5 files changed
+32 -4

No files matched your search

+3
View File
@@ -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
+3
View File
@@ -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
+6
View File
@@ -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
@@ -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");
});
@@ -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