diff --git a/doc/plans/2026-10-20-dependable-everyday-work.md b/doc/plans/2026-10-20-dependable-everyday-work.md index 87df170989d..2863023579b 100644 --- a/doc/plans/2026-10-20-dependable-everyday-work.md +++ b/doc/plans/2026-10-20-dependable-everyday-work.md @@ -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. diff --git a/server/src/__tests__/task-workspace-api.test.ts b/server/src/__tests__/task-workspace-api.test.ts index aaebec41547..07dbc05d74d 100644 --- a/server/src/__tests__/task-workspace-api.test.ts +++ b/server/src/__tests__/task-workspace-api.test.ts @@ -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", diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts index c62d8e407eb..b33426f6a56 100644 --- a/server/src/__tests__/task-workspace-repositories-trust.test.ts +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -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" }); diff --git a/server/src/services/task-repository-acquisition.ts b/server/src/services/task-repository-acquisition.ts index 71c0e07e98b..68dce79c325 100644 --- a/server/src/services/task-repository-acquisition.ts +++ b/server/src/services/task-repository-acquisition.ts @@ -19,6 +19,11 @@ export const taskRepositoryRunColumns = { }; type TaskRepositoryRunIdentity = Pick; +/** 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 = []; for (let depth = 0; parentId && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth++) { diff --git a/server/src/services/task-workspace-access.ts b/server/src/services/task-workspace-access.ts index 609d85a450b..b49ba9d9432 100644 --- a/server/src/services/task-workspace-access.ts +++ b/server/src/services/task-workspace-access.ts @@ -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 } })); diff --git a/tests/runner-e2e/automatic-repository-flow.ts b/tests/runner-e2e/automatic-repository-flow.ts index 49aec15ab83..ba31809b88d 100644 --- a/tests/runner-e2e/automatic-repository-flow.ts +++ b/tests/runner-e2e/automatic-repository-flow.ts @@ -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(`/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}`)) diff --git a/tests/runner-e2e/automatic-repository.test.ts b/tests/runner-e2e/automatic-repository.test.ts index 5bee95ef762..b2a5acb662f 100644 --- a/tests/runner-e2e/automatic-repository.test.ts +++ b/tests/runner-e2e/automatic-repository.test.ts @@ -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); diff --git a/tests/runner-e2e/automatic-repository.ts b/tests/runner-e2e/automatic-repository.ts index 0873d68b884..937a8adb2b2 100644 --- a/tests/runner-e2e/automatic-repository.ts +++ b/tests/runner-e2e/automatic-repository.ts @@ -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 [