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

@@ -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";
}