fix(workspaces): name the owner action when a shared source is restricted (#16545)

A reused task workspace in a shared project checkout keeps its source
denial when another task's restricted worktree shares that checkout
(#16095 deliberately does not clone over existing workspaces). The run
then failed as an opaque `setup_failed` 403 and the task was blocked
with a generic "no live execution path" notice.

Convert that exact denial into a `configuration_incomplete` failure
with reason `workspace_source_restricted`, give the task notice and the
recovery action the owner step, and keep this pre-dispatch owner action
out of the unexpected Sentry run-failure stream. The reason and
predicate live in a dependency-free module. Access checks are unchanged.

Co-Authored-By: Paperclip <Paperclip-Paperclip@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-16 01:33:53 -07:00
1 parent f1d4bc1617
commit 8f1cb11a03
12 files changed
+175 -5

No files matched your search

+5
View File
@@ -51,6 +51,11 @@ New implicit Git workspace defaults record their project-source provenance.
An untyped checkout binding from an older task is ambiguous and keeps the denial,
including explicit checkout IDs and inherited parent sources. Admission rechecks
the current binding rather than inferring a default from the selected checkout ID.
When the denial is retained, the run stops before the agent starts with a
`configuration_incomplete` failure (reason `workspace_source_restricted`). The task
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.
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
@@ -51,6 +51,9 @@ retain the original denial without cloning or replacing their unpublished commit
New implicit Git defaults record project-source provenance. Untyped explicit,
inherited and older ambiguous checkout bindings retain the denial; admission reads
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.
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
@@ -46,6 +46,7 @@ import { buildEffectiveRunWorkspaceConfigMetadata, heartbeatService } from "../s
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { noticeMetadataReferencesRecoveryAction } from "../services/recovery/index.ts";
import { instanceSettingsService } from "../services/instance-settings.ts";
import { waitForPendingRunFailureReports } from "../services/run-failure-report.ts";
import {
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
@@ -101,6 +102,13 @@ const adapterExecute = vi.hoisted(() =>
})),
);
const mockCaptureRunFailure = vi.hoisted(() => vi.fn());
vi.mock("../sentry.ts", async () => {
const actual = await vi.importActual<typeof import("../sentry.ts")>("../sentry.ts");
return { ...actual, captureRunFailure: mockCaptureRunFailure };
});
vi.mock("../adapters/index.js", () => ({
getServerAdapter: () => ({
type: "codex_local",
@@ -1067,6 +1075,39 @@ describeEmbeddedPostgres("heartbeat workspace branch containment", () => {
},
);
it("blocks a reused shared-checkout workspace with the owner action once a restricted task worktree shares it", async () => {
vi.stubEnv("PAPERCLIP_SENTRY_RUN_FAILURES", "unexpected");
const seeded = await seedRetainedSourceRun("deleted");
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");
await runGit(seeded.owner, ["worktree", "add", "-b", "restricted-task", privateCwd]);
const [privateWorkspace] = await db.insert(executionWorkspaces).values({ companyId: seeded.companyId, projectId: seeded.projectId,
sourceIssueId: privateIssue.id, name: "Private workspace", mode: "isolated_workspace", strategyType: "git_worktree",
providerType: "git_worktree", cwd: privateCwd, providerRef: privateCwd, branchName: "restricted-task", status: "idle" }).returning();
await db.update(issues).set({ executionWorkspaceId: privateWorkspace.id }).where(eq(issues.id, privateIssue.id));
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
const run = await waitForRunToFinish(heartbeat, seeded.runId);
await heartbeat.waitForRunExecutionDrain(seeded.runId);
await waitForPendingRunFailureReports();
expect(run).toMatchObject({ status: "failed", errorCode: "configuration_incomplete", resultJson: {
configurationIncomplete: { reason: "workspace_source_restricted", issueId: seeded.issueId },
executionRecovery: { kind: "bootstrap", providerWorkStarted: false } } });
expect(adapterExecute).not.toHaveBeenCalled();
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
const [issue] = await db.select().from(issues).where(eq(issues.id, seeded.issueId));
expect(issue).toMatchObject({ status: "blocked", executionRunId: null, executionWorkspaceId: seeded.executionWorkspaceId });
const comments = await db.select({ body: issueComments.body }).from(issueComments).where(eq(issueComments.issueId, seeded.issueId));
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.map(action => action.nextAction).join("\n")).toContain("select a new workspace");
expect(await readFile(path.join(seeded.worktree, "retained.txt"), "utf8")).toBe("unexported original task edits\n");
});
it("blocks projectless isolated git-worktree issues before dispatch", async () => {
const companyId = randomUUID();
const agentId = randomUUID();
@@ -738,6 +738,40 @@ describeEmbeddedPostgres("reportRunFailure", () => {
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
});
function restrictedWorkspaceSourceRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
return buildRun({
errorCode: "configuration_incomplete",
executionStage: "preparing",
resultJson: {
configurationIncomplete: { reason: "workspace_source_restricted", issueId: randomUUID() },
executionRecovery: { kind: "bootstrap", providerWorkStarted: false },
},
...overrides,
});
}
it("keeps a restricted shared workspace source actionable on the task", async () => {
await seedCompanyAndAgent();
await reportRunFailure(db, restrictedWorkspaceSourceRun(), { phase: "setup" });
expect(mockCaptureRunFailure).not.toHaveBeenCalled();
});
it.each([
{ status: "timed_out" }, { errorCode: "setup_failed" }, { executionStage: "executing" }, { exitCode: 1 }, { signal: "SIGTERM" },
{ resultJson: { configurationIncomplete: { reason: "workspace_source_restricted" } } },
{ resultJson: { configurationIncomplete: { reason: "workspace_source_restricted" }, executionRecovery: { kind: "bootstrap", providerWorkStarted: true } } },
])("reports restricted-source markers without an exact pre-dispatch outcome: %j", async (overrides) => {
await seedCompanyAndAgent();
await reportRunFailure(db, restrictedWorkspaceSourceRun(overrides), { phase: "setup" });
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
});
it.each([undefined, "execute"] as const)("reports restricted-source markers outside the setup report: %s", async (phase) => {
await seedCompanyAndAgent();
await reportRunFailure(db, restrictedWorkspaceSourceRun(), { phase });
expect(mockCaptureRunFailure).toHaveBeenCalledTimes(1);
});
function localPathConfigurationRun(overrides: Partial<typeof heartbeatRuns.$inferSelect> = {}) {
return buildRun({
errorCode: "workspace_validation_failed",
@@ -227,7 +227,8 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
await execFile("git", ["-C", source, "checkout", "main"]);
if (scenario === "literal-branch") input.config.mergedConfig = { workspaceStrategy: { type: "git_worktree", baseRef: "main", branchTemplate: "feature/literal-pin" } };
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Task workspace access is no longer available");
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toMatchObject({ code: "configuration_incomplete",
resultJson: { configurationIncomplete: { reason: "workspace_source_restricted", issueId: issue.id } } });
expect(clone).not.toHaveBeenCalled();
expect(acquire).not.toHaveBeenCalled();
expect((await readIssue(issue.id)).executionWorkspaceId).toBeNull();
@@ -298,6 +299,32 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
expect(await readFile(join(privateCwd, "confidential.txt"), "utf8")).toBe("Private source must not be copied");
});
it("keeps a reused shared-checkout workspace denied with an owner action after a restricted worktree appears", async () => {
const { input, source, issue } = await fixture(true);
const first = await prepareHeartbeatWorkspace(db, input);
const workspace = first.persistedExecutionWorkspace!;
expect(first.executionWorkspace.baseCwd).toBe(source);
await writeFile(join(workspace.cwd!, "unpublished-work.txt"), "Keep this task's local work\n");
const [privateIssue] = await db.insert(issues).values({ companyId: issue.companyId, title: "Private work",
visibility: "private", projectId: issue.projectId }).returning();
const privateCwd = join(source, "private-work");
await execFile("git", ["-C", source, "worktree", "add", "-b", "restricted-task", privateCwd, "main"]);
const [privateWorkspace] = await db.insert(executionWorkspaces).values({ companyId: issue.companyId,
name: "Private workspace", projectId: issue.projectId, sourceIssueId: privateIssue.id, cwd: privateCwd, mode: "isolated_workspace",
strategyType: "git_worktree", providerType: "git_worktree", providerRef: privateCwd, branchName: "restricted-task", status: "idle" }).returning();
await db.update(issues).set({ executionWorkspaceId: privateWorkspace.id }).where(eq(issues.id, privateIssue.id));
await reuse(input, workspace);
const clone = vi.spyOn(managedRepositoryCheckout, "materializeManagedProjectWorkspace");
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toMatchObject({ code: "configuration_incomplete",
resultJson: { configurationIncomplete: { reason: "workspace_source_restricted", issueId: issue.id } } });
expect(clone).not.toHaveBeenCalled();
expect(acquire).not.toHaveBeenCalled();
expect((await readIssue(issue.id)).executionWorkspaceId).toBe(workspace.id);
expect(await readFile(join(workspace.cwd!, "unpublished-work.txt"), "utf8")).toBe("Keep this task's local work\n");
await expect(access(isolatedRemoteSourcePath(issue.companyId, issue.id))).rejects.toMatchObject({ code: "ENOENT" });
});
it.each(["sandbox", "ssh", "local", null] as const)("uses the selected %s placement for controller provisioning", async driver => {
const { input, source, issue, envOrchestrator } = await fixture(true);
const marker = join(source, "host-provision-ran");
@@ -31,6 +31,8 @@ import { parseObject } from "../../adapters/utils.js";
import { materializeNativeChatTaskRoot } from "../native-runtime/native-chat-workspace.js";
import { materializeIsolatedTaskDirectory } from "../isolated-task-directory.js";
import { canIsolateRemoteWorkspaceSource, materializeIsolatedRemoteWorkspaceSource, withIsolatedRepositorySourceLock } from "../isolated-remote-workspace-source.js";
import { isRestrictedWorkspaceSourceDenial, RESTRICTED_WORKSPACE_SOURCE_REASON } from "../workspace-source-restriction.js";
import { ConfigurationIncompleteFailure } from "./run-preparation.js";
import {
assertIsolatedWorkspaceSourceReadAccess,
retainIsolatedWorkspaceSourceAccess,
@@ -148,6 +150,19 @@ function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
/**
* A restricted task worktree in the shared checkout keeps this task's source denied when
* no fresh isolated clone is safe (an existing workspace may hold unpublished work).
* Name the owner action instead of failing as an opaque setup error.
*/
function restrictedWorkspaceSourceFailure(error: unknown, issueId: string): unknown {
if (!isRestrictedWorkspaceSourceDenial(error)) return error;
return new ConfigurationIncompleteFailure(
"This task's workspace source is a shared project checkout that also holds a task workspace this run cannot read, so Paperclip did not use it.",
{ configurationIncomplete: { reason: RESTRICTED_WORKSPACE_SOURCE_REASON, issueId } },
);
}
export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspacePreparationInput) {
const sourceIssueId = input.reuse.reusableExistingExecutionWorkspace?.sourceIssueId
?? input.reuse.existingExecutionWorkspace?.sourceIssueId ?? input.task.issueId;
@@ -510,12 +525,14 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
selection: issues.workspaceSelection }).from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)));
if (!sourceChoice || sourceChoice.executionWorkspaceId
|| (sourceChoice.projectWorkspaceId && sourceChoice.projectWorkspaceId !== resolvedWorkspace.workspaceId)) throw error;
|| (sourceChoice.projectWorkspaceId && sourceChoice.projectWorkspaceId !== resolvedWorkspace.workspaceId)) {
throw restrictedWorkspaceSourceFailure(error, issueId);
}
if (!canIsolateRemoteWorkspaceSource({ error, repoUrl: executionWorkspaceBase.repoUrl,
reuseExisting: requestedShouldReuseExisting, hasExistingWorkspace: Boolean(existingExecutionWorkspace),
selectedProjectWorkspaceId: sourceChoice.projectWorkspaceId ?? readNonEmptyString(context.projectWorkspaceId),
selection: sourceChoice.selection, strategy: parseObject(hostExecutionWorkspaceConfig.workspaceStrategy),
trustPresetKind: trustPreset.kind })) throw error;
trustPresetKind: trustPreset.kind })) throw restrictedWorkspaceSourceFailure(error, issueId);
const cwd = await timeWorkspace("workspace_source_materialize", () => materializeIsolatedRemoteWorkspaceSource({ companyId: agent.companyId,
issueId, repoUrl: executionWorkspaceBase.repoUrl!, resolveGitAuth: workspaceGitAuthProvider }));
executionWorkspaceBase.baseCwd = cwd;
@@ -10,6 +10,7 @@ import type { GitRemoteAuthProvider } from "./git-credentials.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { materializeManagedProjectWorkspace } from "./managed-repository-checkout.js";
import { measureRunPhase } from "./execution-preparation-timing.js";
import { isRestrictedWorkspaceSourceDenial } from "./workspace-source-restriction.js";
const execFile = promisify(execFileCallback);
@@ -88,11 +89,10 @@ export function canIsolateRemoteWorkspaceSource(input: {
strategy: Record<string, unknown>;
trustPresetKind: string;
}): boolean {
const error = input.error as { status?: unknown; message?: unknown } | null;
const branchTemplate = input.strategy.branchTemplate;
const selectsFixedBranch = typeof branchTemplate === "string" && branchTemplate.length > 0
&& !/\{\{\s*(?:issue\.(?:id|identifier|title)|slug)\s*\}\}/.test(branchTemplate);
if (input.trustPresetKind !== "standard" || error?.status !== 403 || error.message !== "Task workspace access is no longer available"
if (input.trustPresetKind !== "standard" || !isRestrictedWorkspaceSourceDenial(input.error)
|| input.reuseExisting || input.hasExistingWorkspace || input.strategy.existingBranch || selectsFixedBranch
|| (input.selection && !["project", "task_default"].includes(input.selection.source))
|| (input.selectedProjectWorkspaceId && (input.selection?.selection.kind !== "configured_source"
+3
View File
@@ -1,6 +1,7 @@
import { isAgentAwaitingSetup } from "../../modules/agent-lifecycle/index.js";
import { onboardingFirstTaskAwaitingUserSql } from "./onboarding-first-task-wait.js";
import { isAiAuthenticationBlocked } from "../ai-auth-failure.js";
import { RESTRICTED_WORKSPACE_SOURCE_REASON } from "../workspace-source-restriction.js";
import { hasCommittedNativePlanWait } from "../native-runtime/native-plan-wait.js";
import { isNativeWorkspaceExportRepairCause } from "@paperclipai/shared";
import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
@@ -2595,6 +2596,8 @@ export function recoveryService(
? "Check the starting branch and repository access, then repair the task’s branch and retry the original assignee."
: readConfigurationIncompletePayload(input.latestRun)?.reason === "ai_connection_unavailable"
? "Reconnect the selected AI account or choose an available connection, then continue the task."
: readConfigurationIncompletePayload(input.latestRun)?.reason === RESTRICTED_WORKSPACE_SOURCE_REASON
? "Push any unpublished work from this task's workspace, then select a new workspace for the task, or have an owner grant access to the restricted task. Then retry the original assignee."
: readConfigurationIncompletePayload(input.latestRun)
?.reason === SANDBOX_PROVIDER_PLUGIN_NOT_READY_REASON
? `Board operator: the sandbox provider plugin named in the run failure is not ready; ${sandboxProviderPluginRemedy(
@@ -76,6 +76,14 @@ describe("stranded recovery notice seeds", () => {
expect(seed.nextAction).toContain("stop it if it is stuck");
});
it("explains a shared checkout restricted by another task instead of missing secrets", () => {
const seed = buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "workspace_source_restricted" });
expect(seed.title).toBe("Workspace source restricted");
expect(seed.body).toContain("shared project checkout");
expect(seed.body).not.toContain("secret/env bindings");
expect(seed.nextAction).toContain("select a new workspace");
});
it("keeps the secret-binding copy for other configuration gaps", () => {
expect(buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "secret_binding_missing" }).body).toContain(
"secret/env bindings",
@@ -137,6 +137,14 @@ export function buildConfigurationIncompleteRecoveryNoticeSeed(
tone: "danger",
};
}
if (readNonEmptyStringField(configurationIncomplete, "reason") === "workspace_source_restricted") {
return {
title: "Workspace source restricted",
body: "Paperclip stopped before the agent started because this task's workspace uses a shared project checkout that also holds another task's workspace, and this run cannot read that task. Paperclip does not copy a shared checkout with restricted files.",
nextAction: "Push any unpublished work from this task's workspace, then select a new workspace for the task. Alternatively, have an owner grant access to the restricted task. Then retry the task.",
tone: "danger",
};
}
if (readNonEmptyStringField(configurationIncomplete, "reason") === "ai_connection_unavailable") {
return {
title: "AI connection needs attention",
+15
View File
@@ -14,6 +14,7 @@ import { isUnexpectedRunCancellation } from "./run-cancellation.js";
import { readConnectionFailure, type ConnectionFailure } from "@paperclipai/adapter-utils/connection-failure";
import { isAiConnectionConfigurationReason } from "./ai-connection-configuration-failure.js";
import { readRunFailureReportingMode, type RunFailureReportingMode } from "../sentry-run-failure-policy.js";
import { RESTRICTED_WORKSPACE_SOURCE_REASON } from "./workspace-source-restriction.js";
type HeartbeatRun = typeof heartbeatRuns.$inferSelect;
@@ -123,6 +124,19 @@ function isLocalPathWorkspaceConfigurationBlocker(run: HeartbeatRun, options: Ru
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
/** A retained shared checkout denied by task privacy before dispatch; the task notice names the owner action. */
function isRestrictedWorkspaceSourceBlocker(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
if (
run.status !== "failed" || run.errorCode !== "configuration_incomplete" ||
run.executionStage !== "preparing" || options.phase !== "setup" ||
run.exitCode != null || run.signal != null
) return false;
const configuration = asRecord(run.resultJson?.configurationIncomplete);
const recovery = asRecord(run.resultJson?.executionRecovery);
return configuration?.reason === RESTRICTED_WORKSPACE_SOURCE_REASON &&
recovery?.kind === "bootstrap" && recovery.providerWorkStarted === false;
}
/** Only the managed-clone producer can attach this bounded pre-dispatch evidence. */
function isGitConnectionFailure(run: HeartbeatRun, options: RunFailureReportOptions): boolean {
const connection = readConnectionFailure(run.resultJson?.connectionFailure);
@@ -242,6 +256,7 @@ export function reportRunFailure(db: Db, run: HeartbeatRun, options: RunFailureR
isMissingSecretConfigurationBlocker(run, options) ||
isAiConnectionSelectionBlocker(run, options) ||
isLocalPathWorkspaceConfigurationBlocker(run, options) ||
isRestrictedWorkspaceSourceBlocker(run, options) ||
isGitConnectionFailure(run, options) ||
isProviderQuotaFailure(run, options) ||
isCodexAcpModelCapacityFailure(run, options) ||
@@ -0,0 +1,9 @@
// Dependency-free so run-failure reporting can classify this outcome without
// loading the checkout and credential graph behind isolated source handling.
export const RESTRICTED_WORKSPACE_SOURCE_REASON = "workspace_source_restricted";
/** The shared checkout holds a task workspace that the run's actor cannot read. */
export function isRestrictedWorkspaceSourceDenial(error: unknown): boolean {
const denial = error as { status?: unknown; message?: unknown } | null;
return denial?.status === 403 && denial.message === "Task workspace access is no longer available";
}