mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:31:54 +01:00
fix: retain local-board responsibility during repository preparation
This commit is contained in:
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++) {
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in new issue
Block a user