fix: retain local-board responsibility during repository preparation

This commit is contained in:
Dotta committed 2026-10-21 07:19:00 -05:00
1 parent 12b2a8eff9
commit f220914293
8 files changed
+84 -15

No files matched your search

@@ -70,3 +70,12 @@ Server typecheck passes. These results include long conversation lineages,
cycles, broken ledger receipts, current-task repository ownership, and completed
repository rejection. Explicit completion and review modes remain distinct from
an unfinished standard-task yield. They do not establish the live matrix.
A fresh local provider pilot exposed a separate repository authorization defect:
the native API retained the local-board identity while the workspace fence
expected its older null representation. A native-tool/HTTP/PostgreSQL regression
reproduces the rejection before the fix. The repaired authorization cohort passes
78 checks, including revoked membership, changed sponsor, and restricted child
workspace cases. A committed setup blocker now ends the E2E attempt immediately
instead of consuming its remaining deadline. The failed pilot remains evidence;
these regressions do not replace a fresh provider run.
@@ -2,8 +2,8 @@ import { randomUUID } from "node:crypto";
import { mkdtemp } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db";
import { and, eq } from "drizzle-orm";
import { agents, authUsers, companyMemberships, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
@@ -15,6 +15,28 @@ const support = await getEmbeddedPostgresTestSupport();
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000);
afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; });
it("prepares a repository through native JWT authority for the local board and rechecks revoked access", async () => {
const f = await server.fixture({ disableWakeOnDemand: true });
await server.db.insert(authUsers).values({ id: "local-board", name: "Local Board", email: "local-board@example.invalid",
emailVerified: true, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: "local-board", membershipRole: "owner", status: "active" });
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
name: "Local task files", cwd: f.workspace, mode: "isolated_workspace", strategyType: "task_directory" }).returning();
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id,
responsibleUserId: "local-board" }).where(eq(issues.id, f.issueId));
await server.db.update(heartbeatRuns).set({ responsibleUserId: "local-board" }).where(eq(heartbeatRuns.id, f.runId));
await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: {
repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "local-board-native" } }))
.resolves.toMatchObject({ kind: "requires_next_admission" });
await server.db.delete(companyMemberships).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, "local-board")));
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, "local-board");
const denied = await fetch(`${server.apiUrl}/api/issues/${f.issueId}/workspace/repositories`, {
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ repository: { kind: "url", url: "https://github.com/public/other" }, requestKey: "different-sponsor" }),
});
expect([403, 404]).toContain(denied.status);
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(1);
});
it("creates an explicit-workspace child through the authenticated low-trust API without repeating its saved policy", async () => {
const f = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true });
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
@@ -7,7 +7,7 @@ import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces,
heartbeatRuns, issues, projects, projectWorkspaces, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
heartbeatRuns, issues, projects, projectWorkspaces, projectAccessMembers, companyMemberships, authUsers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
@@ -334,14 +334,21 @@ const support = await getEmbeddedPostgresTestSupport();
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it.each(["sponsored", "local_board", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => {
it.each(["sponsored", "local_board", "local_board_jwt", "routine"] as const)("prepares public Git from an idle workspace for a trusted %s task and reuses verified bytes after trust downgrade", async principal => {
const f = await fixture();
await db.update(executionWorkspaces).set({ status: "idle" }).where(eq(executionWorkspaces.id, f.workspace.id));
if (principal === "local_board") await db.update(heartbeatRuns).set({ responsibleUserId: "local-board" }).where(eq(heartbeatRuns.id, f.runId));
const localBoard = principal === "local_board" || principal === "local_board_jwt";
if (localBoard) await db.update(heartbeatRuns).set({ responsibleUserId: "local-board" }).where(eq(heartbeatRuns.id, f.runId));
if (principal === "local_board_jwt") {
// The production local-trusted company route records this owner membership.
await db.insert(authUsers).values({ id: "local-board", name: "Local Board", email: "local-board@example.invalid",
emailVerified: true, createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: "local-board", membershipRole: "owner", status: "active" });
}
const receipt = await f.service.request({ companyId: f.companyId, issueId: f.issueId,
actor: principal === "local_board" ? { ...f.actor, onBehalfOfUserId: null } : f.actor,
actor: localBoard ? { ...f.actor, onBehalfOfUserId: principal === "local_board_jwt" ? "local-board" : null } : f.actor,
request: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: randomUUID() } });
const responsibleUserId = principal === "local_board" ? "local-board" : principal === "routine" ? null : f.userId;
const responsibleUserId = localBoard ? "local-board" : principal === "routine" ? null : f.userId;
await db.update(heartbeatRuns).set({ responsibleUserId }).where(eq(heartbeatRuns.id, f.runId));
const admission = { ...f.admission, responsibleUserId };
const source = path.join(root, `${f.issueId}-public-source`);
@@ -383,6 +390,14 @@ const support = await getEmbeddedPostgresTestSupport();
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it.each(["local-board", null, "changed-human"])("rejects the former sponsor after responsibility changes to %s", async responsibleUserId => {
const f = await fixture();
await db.update(heartbeatRuns).set({ responsibleUserId }).where(eq(heartbeatRuns.id, f.runId));
await expect(f.request({ kind: "url", url: "https://github.com/public/example" })).rejects.toThrow(
responsibleUserId === null ? "Repository access requires a responsible user" : "Task workspace responsible user changed");
expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]);
});
it("reauthorizes a public clone before publication after trust changes during Git", async () => {
const f = await fixture();
const receipt = await f.request({ kind: "url", url: "https://github.com/public/example" });
@@ -19,6 +19,11 @@ export const taskRepositoryRunColumns = {
};
type TaskRepositoryRunIdentity = Pick<typeof heartbeatRuns.$inferSelect, "nativeIssueId" | "issueId" | "contextSnapshot">;
/** Native JWTs retain the local-board marker; older controller actors omit it. */
export function taskRepositoryResponsibleUserMatches(actorUserId: string | null | undefined, runUserId: string | null) {
return (actorUserId ?? null) === runUserId || (runUserId === "local-board" && actorUserId == null);
}
// A wake for another task can coalesce into a running run and replace its context
// task after admission. The immutable task binding still identifies the admitted work.
export function runRetainsIssueIdentity(run: TaskRepositoryRunIdentity, issueId: string) {
@@ -85,8 +90,7 @@ export async function assertSelectedWorkspaceTrust(db: Db | DbTransaction, input
if (selected.kind !== "low_trust_review" || current.kind !== "low_trust_review"
|| !isDeepStrictEqual(selected.boundary, current.boundary)) throw incompatible();
if (creation) {
const sponsor = run!.responsibleUserId === "local-board" ? null : run!.responsibleUserId;
if ((input.actor.onBehalfOfUserId ?? null) !== sponsor) throw incompatible();
if (!taskRepositoryResponsibleUserMatches(input.actor.onBehalfOfUserId, run!.responsibleUserId)) throw incompatible();
let parentId = creation.parentIssueId;
const ancestors: Array<typeof issues.$inferSelect> = [];
for (let depth = 0; parentId && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth++) {
+2 -2
View File
@@ -5,7 +5,7 @@ import { forbidden } from "../errors.js";
import { accessService } from "./access.js";
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { canonicalExecutionUuid } from "./execution-identity.js";
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns } from "./task-repository-acquisition.js";
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { isDeepStrictEqual } from "node:util";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
@@ -78,7 +78,7 @@ export async function assertTaskWorkspaceExecutionAccess(
|| run.agentId !== actor.agentId || !runRetainsIssueIdentity(run, task.id)))) {
throw forbidden("Task workspace run identity is unavailable");
}
if (run && (actor.onBehalfOfUserId ?? null) !== (run.responsibleUserId === "local-board" ? null : run.responsibleUserId)) {
if (run && !taskRepositoryResponsibleUserMatches(actor.onBehalfOfUserId, run.responsibleUserId)) {
throw forbidden("Task workspace responsible user changed");
}
const decision = await measureRunPhase("workspace.access.task_authorization", () => access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: task.id } }));
@@ -6,7 +6,7 @@ import { expect, type Page } from "@playwright/test";
import { pollUntil, type RunnerApi } from "./api.js";
import { createTaskThroughUi } from "./user-actions.js";
import { readRegisteredArtifacts } from "./registered-artifact.js";
import { automaticRepositoryFiles, gradeAutomaticRepository, type AutomaticRepositoryObservation } from "./automatic-repository.js";
import { automaticRepositoryFiles, automaticRepositoryStopReason, gradeAutomaticRepository, type AutomaticRepositoryObservation } from "./automatic-repository.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
@@ -81,8 +81,7 @@ export async function runAutomaticRepositoryFlow(input: {
await pollUntil({ label: "repository setup and automatic result delivery", deadlineAt: input.deadlineAt, intervalMs: 750,
load: async () => { await refresh(); return { issue, runs }; },
accept: () => observation.issueDone && observation.successfulCommittedRuns === 2,
reject: state => state.runs.length > 2 ? "Unexpected extra provider admission" :
state.runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status)) ? "A provider admission failed" : undefined });
reject: state => state.issue ? automaticRepositoryStopReason({ status: state.issue.status }, state.runs.map(run => ({ status: run.status }))) : undefined });
const view = await api.get<Row>(`/api/issues/${issue.id}/workspace`);
const cwd = await realpath(view.workspace.cwd), isolated = await realpath(input.temporaryRoot);
if (!cwd.startsWith(`${isolated}${path.sep}`) || !cwd.endsWith(`/isolated-workspaces/${fixtures.company.id}/${issue.id}`))
+12 -1
View File
@@ -1,5 +1,5 @@
import { expect, it } from "vitest";
import { gradeAutomaticRepository, type AutomaticRepositoryObservation } from "./automatic-repository.js";
import { automaticRepositoryStopReason, gradeAutomaticRepository, type AutomaticRepositoryObservation } from "./automatic-repository.js";
const sha = "a".repeat(40);
const reference = "c".repeat(64);
@@ -15,6 +15,17 @@ it("accepts independent checkout, retained context, and accessible registered ou
expect(gradeAutomaticRepository(valid).every(check => check.passed)).toBe(true);
});
it("reports a committed setup blocker immediately instead of waiting out the attempt deadline", () => {
expect(automaticRepositoryStopReason({ status: "blocked" }, [{ status: "succeeded" }])).toContain("stopped in blocked");
expect(automaticRepositoryStopReason({ status: "done" }, [{ status: "succeeded" }])).toContain("without its automatic successor");
});
it("keeps waiting for an ordinary queued successor and accepts two completed admissions", () => {
expect(automaticRepositoryStopReason({ status: "in_progress" }, [{ status: "succeeded" }])).toBeUndefined();
expect(automaticRepositoryStopReason({ status: "in_progress" }, [{ status: "succeeded" }, { status: "queued" }])).toBeUndefined();
expect(automaticRepositoryStopReason({ status: "done" }, [{ status: "succeeded" }, { status: "succeeded" }])).toBeUndefined();
});
it("rejects unexpected result fields rather than trusting a model's extra claims", () => {
const extra = { ...valid, downloaded: { ...valid.downloaded, claimedPass: true } };
expect(gradeAutomaticRepository(extra).find(check => check.id === "independent-result")?.passed).toBe(false);
+9
View File
@@ -51,6 +51,15 @@ export interface AutomaticRepositoryObservation {
monitorScheduled: boolean;
}
export function automaticRepositoryStopReason(issue: { status: string }, runs: readonly { status: string }[]) {
if (runs.length > 2) return "Unexpected extra provider admission";
if (runs.some(run => ["failed", "cancelled", "timed_out"].includes(run.status))) return "A provider admission failed";
if (runs.length && runs.every(run => run.status === "succeeded")) {
if (["blocked", "in_review", "cancelled"].includes(issue.status)) return `Repository setup stopped in ${issue.status}; see retained task state`;
if (issue.status === "done" && runs.length !== 2) return "Task finished without its automatic successor";
}
}
export function gradeAutomaticRepository(value: AutomaticRepositoryObservation) {
const check = (id: string, passed: boolean, detail: string) => ({ id, passed, detail });
return [