From 0e0557f123646847629956ff33cdaa2e17418bc6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 11 Oct 2026 06:38:47 -0500 Subject: [PATCH] Fix repository trust and settled workspace recovery guards Co-Authored-By: Paperclip --- doc/plans/2026-10-09-task-workspaces.md | 4 + .../__tests__/task-workspace-binding.test.ts | 79 ++++++- ...workspace-repositories-persistence.test.ts | 27 ++- .../task-workspace-repositories-trust.test.ts | 198 ++++++++++++++++++ .../execution-workspace-repositories.ts | 88 +++++++- server/src/services/execution-workspaces.ts | 60 +++++- 6 files changed, 424 insertions(+), 32 deletions(-) create mode 100644 server/src/__tests__/task-workspace-repositories-trust.test.ts diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md index f83acf17dcf..cb0be24c749 100644 --- a/doc/plans/2026-10-09-task-workspaces.md +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -37,6 +37,8 @@ Typed selection vocabulary: task_directory; existing execution workspace; config Source inspection and isolated copies require source-project read access. Writable shared roots additionally require the internal `project:write_workspace` authorization action. It reuses existing `tasks:assign` and `tasks:assign_scope` grants for the source project and actual assignee, but evaluates only the source project’s assignment policy: continuing a task does not reassign its protected task, parent, or agent. Task lineage still enforces low-trust containment, and the responsible-user intersection applies independently. The resolved strategy determines sharing: only `git_worktree` creates a separate checkout; an isolated mode label does not exempt a source-root strategy. Board requests use the task's actual assignee when checking scoped grants. Check that authority at selection, task creation, repository mutation, and each new admission; revocation blocks future use without changing already-admitted recovery inputs. +Independent repository acquisition does not inherit private-repository authority from a chat endpoint's sponsor. Effective low-trust tasks may submit anonymous repository URLs only; catalog selection, managed credentials and reuse of managed repository receipts remain forbidden. Resolve current agent, task, organizational project and workspace-source policies together with the retained run policy at request and admission. Existing configured-source authorization remains the path for private sources. A trust change must fail before cloning or reusing existing private bytes. + Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally. The organizational task project retains task identity, environment, and secret scope regardless of the filesystem source. Both task-project and source-project policies constrain run trust; source policy controls workspace realization. Channel task creation separately checks assignment authority for the effective organizational project and actual assignee against locked endpoint values inside its transaction. Saving defaults does not grant future senders that authority. @@ -57,6 +59,8 @@ Seed recovery deliberately uses **no delta chain** in this implementation. An un Ordinary settlement remains host-durable before successful completion. Preserve export-only recovery and exact-source lease retention. Preserve the specific unsafe-export omission exception and its run-log-only diagnostic. Do not change global merge conflict semantics as a side effect. +Folder-change guards track live run ownership, finalization work and pending file recovery. A historical stopped or declined run whose cleanup has settled must not permanently block the task's next folder choice. Retained export recovery and unresolved ownership still block changes until their durable release or persistence receipts exist. + ## Delivery and ownership The core implementation owns execution-workspace/task binding schema + shared selection contracts, workspace domain/resolver, issues/heartbeat integration and nullable-project read models. The persistence implementation owns adapter-utils/native sync/trace helpers, exposes integration hooks to core rather than concurrent heartbeat editing. The repository implementation owns acquisition service, inventory schema and routes/runner catalogs, coordinating domain interfaces with core. The integration layer owns channel defaults/config API/UI, documentation, migration generation and final integration/verification. All workers are in the same checkout; do not revert others. diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts index 91542a6ac8e..83df5d0d36b 100644 --- a/server/src/__tests__/task-workspace-binding.test.ts +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -13,7 +13,7 @@ import request from "supertest"; import { taskWorkspaceRoutes } from "../routes/task-workspaces.js"; import { issueRoutes } from "../routes/issues.js"; import { errorHandler } from "../middleware/error-handler.js"; -import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositories, executionWorkspaces, heartbeatRuns, issues, principalPermissionGrants, projects, projectWorkspaces } from "@paperclipai/db"; +import { agents, authUsers, companies, companyMemberships, completionContracts, createDb, environmentLeases, executionWorkspaceRepositories, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, workspaceOperations, principalPermissionGrants, projects, projectWorkspaces } from "@paperclipai/db"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; import { authorizationService, canActorReadExecutionWorkspace } from "../services/authorization.js"; import { assertChatExecutionDefaultsAccess } from "../services/chat-execution-defaults.js"; @@ -196,6 +196,83 @@ const support = await getEmbeddedPostgresTestSupport(); await expect(svc.selectTaskWorkspace({ ...request, requestKey: "stale" })).rejects.toThrow("binding changed"); }); + describe.each(["pending selection", "legacy update"] as const)("native settlement before %s", (entryPoint) => { + let isolatedWorkspacesEnabled: boolean; + beforeAll(async () => { + isolatedWorkspacesEnabled = (await instanceSettingsService(db).getExperimental()).enableIsolatedWorkspaces; + await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); + }); + afterAll(async () => { await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: isolatedWorkspacesEnabled }); }); + async function settledNativeTask(failureCode = "native_retry_cancelled", acceptedResult = false) { + const f = await fixture(), svc = executionWorkspaceService(db); + await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId); + const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Stopped worker" }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent.id, + nativeIssueId: f.issueId, runtimeMode: "native", nativePhase: "terminal_failure", status: "cancelled", + contextSnapshot: {}, finishedAt: new Date() }).returning(); + let resultId: string | null = null; + if (acceptedResult) { + const [contract] = await db.insert(completionContracts).values({ companyId: f.companyId, issueId: f.issueId, + revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "low", + completionAuthority: "agent", incompleteCriteriaPolicy: "block", contractJson: {}, canonicalSha256: "contract", + createdByActorType: "agent", createdByActorId: agent.id }).returning(); + await db.update(heartbeatRuns).set({ completionContractId: contract.id }).where(eq(heartbeatRuns.id, run.id)); + const [result] = await db.insert(nativeRunResults).values({ companyId: f.companyId, issueId: f.issueId, runId: run.id, + completionContractId: contract.id, serverFingerprint: randomUUID(), schemaStatus: "accepted", resultJson: {}, canonicalSha256: "result" }).returning(); + resultId = result.id; + } + await db.insert(nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issueId, runId: run.id, + phase: "terminal_failure", failureCode, resultId }); + const [lease] = await db.insert(environmentLeases).values({ companyId: f.companyId, issueId: f.issueId, + heartbeatRunId: run.id, executionWorkspaceId: f.workspaceId, status: "released", cleanupStatus: "success", releasedAt: new Date() }).returning(); + if (entryPoint === "pending selection") await svc.selectTaskWorkspace({ ...f, selection: { kind: "task_directory" }, expectedBindingRevision: 1, requestKey: "after-stop" }); + const change = () => entryPoint === "pending selection" + ? svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() }) + : issueService(db).update(f.issueId, { executionWorkspaceId: null }); + return { ...f, run, lease, svc, change }; + } + + it.each(["native_retry_cancelled", "native_permission_declined"])("allows a settled %s without deleting its historical failure", async (failureCode) => { + const f = await settledNativeTask(failureCode); + await f.change(); + expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: 2, pendingSelection: null }); + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.run.id)))[0]) + .toMatchObject({ phase: "terminal_failure", failureCode }); + }); + + it.each(["running native owner", "coordinator owner", "applied result", "pending cleanup", "failed cleanup", "export marker", "active lease", "failed output", "accepted output"] as const)("keeps %s fenced until its durable obligation settles", async (obligation) => { + const f = await settledNativeTask("native_retry_cancelled", obligation === "accepted output"); + if (obligation === "running native owner") await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, f.run.id)); + if (obligation === "coordinator owner") await db.update(nativeRunFinalizations).set({ leaseOwner: "controller" }).where(eq(nativeRunFinalizations.runId, f.run.id)); + if (obligation === "applied result") await db.update(nativeRunFinalizations).set({ phase: "applied" }).where(eq(nativeRunFinalizations.runId, f.run.id)); + if (obligation === "pending cleanup") await db.update(environmentLeases).set({ status: "pending_cleanup" }).where(eq(environmentLeases.id, f.lease.id)); + if (obligation === "failed cleanup") await db.update(environmentLeases).set({ cleanupStatus: "failed" }).where(eq(environmentLeases.id, f.lease.id)); + if (obligation === "export marker") await db.update(environmentLeases).set({ metadata: { nativeWorkspaceExportResume: { runId: f.run.id } } }).where(eq(environmentLeases.id, f.lease.id)); + if (obligation === "active lease") await db.update(environmentLeases).set({ status: "active", releasedAt: null }).where(eq(environmentLeases.id, f.lease.id)); + if (obligation === "failed output") await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId, + heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "failed" }); + await expect(f.change()).rejects.toThrow(/workspace|Previous task run/); + expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: { id: f.workspaceId }, bindingRevision: 1 }); + if (obligation === "failed output" || obligation === "accepted output") { + await db.update(workspaceOperations).set({ createdAt: new Date(0) }).where(eq(workspaceOperations.heartbeatRunId, f.run.id)); + await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId, + heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "succeeded", finishedAt: new Date() }); + await f.change(); + expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: 2 }); + } + }); + + it.each(["warm release", "unsafe export omission"] as const)("accepts a settled %s receipt", async (receipt) => { + const f = await settledNativeTask("native_retry_cancelled", receipt === "unsafe export omission"); + if (receipt === "warm release") await db.update(environmentLeases).set({ releasedAt: null, status: "retained", leasePolicy: "reuse_by_environment", cleanupStatus: "success" }).where(eq(environmentLeases.id, f.lease.id)); + if (receipt === "unsafe export omission") await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId, + heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "succeeded", + metadata: { workspaceSync: { omitted: true } }, finishedAt: new Date() }); + await f.change(); + expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: 2 }); + }); + }); + it("preserves a queued next-run choice through first binding and its original retry receipt", async () => { const f = await fixture(), svc = executionWorkspaceService(db); const [nextWorkspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, diff --git a/server/src/__tests__/task-workspace-repositories-persistence.test.ts b/server/src/__tests__/task-workspace-repositories-persistence.test.ts index f2a19b5f1ab..e2e328131c6 100644 --- a/server/src/__tests__/task-workspace-repositories-persistence.test.ts +++ b/server/src/__tests__/task-workspace-repositories-persistence.test.ts @@ -8,7 +8,7 @@ import path from "node:path"; import { constants as fsConstants } from "node:fs"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { and, eq } from "drizzle-orm"; -import { agents, companies, createDb, executionWorkspaces, executionWorkspaceRepositories, issues } from "@paperclipai/db"; +import { agents, companies, createDb, executionWorkspaces, executionWorkspaceRepositories, heartbeatRuns, issues } from "@paperclipai/db"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js"; import { listLocalProjectRepositories } from "@paperclipai/adapter-utils/ssh"; @@ -35,6 +35,11 @@ const support = await getEmbeddedPostgresTestSupport(); [issueId] = (await db.insert(issues).values({ companyId, title: "Projectless task", executionWorkspaceId: workspaceId }).returning()).map(row => row.id); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => { vi.unstubAllEnvs(); await temporary?.cleanup(); if (cwd) await rm(cwd, { recursive: true, force: true }); }); + const prepareForAdmission = async (input: Parameters["prepareForAdmission"]>[0]) => { + await db.insert(heartbeatRuns).values({ id: input.runId, companyId: input.companyId, agentId: input.agentId, + status: "running", responsibleUserId: input.responsibleUserId, contextSnapshot: { issueId: input.issueId } }).onConflictDoNothing(); + return executionWorkspaceRepositoryService(db).prepareForAdmission(input); + }; const request = (requestKey: string, ref?: string) => executionWorkspaceRepositoryService(db).request({ companyId, issueId, actor, request: { repository: { kind: "catalog", id: "123" }, requestKey, ...(ref ? { ref } : {}) } }); it("publishes a requested repository from an absent path through real admission locking and cloning", async () => { const root = await mkdtemp(path.join(tmpdir(), "paperclip-fresh-repository-")); @@ -88,13 +93,13 @@ const support = await getEmbeddedPostgresTestSupport(); .where(eq(executionWorkspaceRepositories.id, readyIntent.operationId)); // New service instances emulate the controller restart between request // and admission; concurrent admissions must publish one owned checkout. - const pending = Promise.all([1, 2].map(() => executionWorkspaceRepositoryService(db).prepareForAdmission(admission))); + const pending = Promise.all([1, 2].map(() => prepareForAdmission(admission))); let results: Awaited; let timeout: ReturnType | undefined; try { await vi.waitFor(async () => expect(await lstat(cloneStarted).catch(() => null)).not.toBeNull()); const ready = await Promise.race([ - executionWorkspaceRepositoryService(db).prepareForAdmission({ ...admission, workspaceId: readyWorkspace.id, issueId: readyTask.id }), + prepareForAdmission({ ...admission, workspaceId: readyWorkspace.id, issueId: readyTask.id, runId: randomUUID() }), new Promise((_, reject) => { timeout = setTimeout(() => reject(new Error("Unrelated repository reuse waited for a blocked clone")), 2000); }), ]); expect(ready[0]).toMatchObject({ id: readyIntent.operationId, pinnedCommit: pin }); @@ -123,7 +128,7 @@ const support = await getEmbeddedPostgresTestSupport(); expect((await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, intent.operationId)))[0]) .toMatchObject({ state: "ready", pinnedCommit: pin, failureCode: null }); await writeFile(path.join(checkout, "source.txt"), "retained dirty work\n"); - await executionWorkspaceRepositoryService(db).prepareForAdmission(admission); + await prepareForAdmission(admission); expect(await readFile(path.join(checkout, "source.txt"), "utf8")).toBe("retained dirty work\n"); } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; @@ -146,7 +151,7 @@ const support = await getEmbeddedPostgresTestSupport(); const [task] = await db.insert(issues).values({ companyId, title: "Unsafe staging", executionWorkspaceId: workspace.id }).returning(); await executionWorkspaceRepositoryService(db).request({ companyId, issueId: task.id, actor, request: { repository: { kind: "catalog", id: "123" }, requestKey: "unsafe-staging" } }); - await expect(executionWorkspaceRepositoryService(db).prepareForAdmission({ companyId, issueId: task.id, + await expect(prepareForAdmission({ companyId, issueId: task.id, workspaceId: workspace.id, cwd: taskRoot, agentId, runId: randomUUID(), responsibleUserId: "local-board" })) .rejects.toThrow(unsafeEntry === "parent" ? "staging directory escapes" : "ignore file is not a regular file"); expect(await readdir(outside)).toEqual([]); @@ -174,7 +179,7 @@ const support = await getEmbeddedPostgresTestSupport(); catalog.available = false; try { await expect(request("one")).rejects.toThrow(/no longer available/); - await expect(executionWorkspaceRepositoryService(db).prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId, runId: randomUUID(), responsibleUserId: "local-board" })).rejects.toThrow(/no longer available/); + await expect(prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId, runId: randomUUID(), responsibleUserId: "local-board" })).rejects.toThrow(/no longer available/); } finally { catalog.available = true; } }); it("retains durable ownership when Git bundle restoration omits the temporary receipt", async () => { @@ -190,7 +195,7 @@ const support = await getEmbeddedPostgresTestSupport(); const commit = await git("rev-parse", "HEAD"); await db.update(executionWorkspaceRepositories).set({ pinnedCommit: commit, state: "ready" }).where(eq(executionWorkspaceRepositories.id, inventory.id)); await writeFile(path.join(checkout, "source.txt"), "uncommitted work\n"); - const prepared = await executionWorkspaceRepositoryService(db).prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId, runId: randomUUID(), responsibleUserId: "local-board" }); + const prepared = await prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId, runId: randomUUID(), responsibleUserId: "local-board" }); expect(prepared).toHaveLength(1); expect(prepared[0].pinnedCommit).toBe(commit); expect(await readFile(path.join(checkout, "source.txt"), "utf8")).toBe("uncommitted work\n"); @@ -202,7 +207,7 @@ const support = await getEmbeddedPostgresTestSupport(); let deadline: ReturnType | undefined; try { await Promise.race([ - executionWorkspaceRepositoryService(db).prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId, runId: randomUUID(), responsibleUserId: "local-board" }), + prepareForAdmission({ companyId, issueId, workspaceId, cwd, agentId, runId: randomUUID(), responsibleUserId: "local-board" }), new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error("FIFO receipt blocked admission")), 3000); }), ]); } finally { @@ -223,7 +228,7 @@ const support = await getEmbeddedPostgresTestSupport(); const intent = await service.request({ companyId, issueId: secondIssue.id, actor, request: { repository: { kind: "catalog", id: "123" }, requestKey: "shared-second" } }); const admission = { companyId, issueId: secondIssue.id, workspaceId: secondWorkspace.id, cwd, agentId: secondAgent.id, runId: randomUUID(), responsibleUserId: null }; const [original] = await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, workspaceId)); - const prepared = await service.prepareForAdmission(admission); + const prepared = await prepareForAdmission(admission); expect(prepared[0]).toMatchObject({ id: intent.operationId, cwd: path.join(await realpath(cwd), original.relativePath), pinnedCommit: original.pinnedCommit }); expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n"); // There is still one physical checkout and neither its files nor Git metadata were rewritten. @@ -231,7 +236,7 @@ const support = await getEmbeddedPostgresTestSupport(); const [differentRefWorkspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Conflicting ref", cwd, mode: "shared_workspace", strategyType: "operator_directory" }).returning(); const [differentRefIssue] = await db.insert(issues).values({ companyId, title: "Different ref", executionWorkspaceId: differentRefWorkspace.id }).returning(); await service.request({ companyId, issueId: differentRefIssue.id, actor, request: { repository: { kind: "catalog", id: "123" }, ref: "release", requestKey: "conflicting-ref" } }); - await expect(service.prepareForAdmission({ ...admission, issueId: differentRefIssue.id, workspaceId: differentRefWorkspace.id })).rejects.toThrow(/different ref/); + await expect(prepareForAdmission({ ...admission, issueId: differentRefIssue.id, workspaceId: differentRefWorkspace.id, runId: randomUUID() })).rejects.toThrow(/different ref/); const [privateSource] = await db.insert(issues).values({ companyId, title: "Retained private source", visibility: "private" }).returning(); const owner = executionWorkspaceService(db); await owner.bindTaskWorkspace(companyId, privateSource.id, workspaceId); @@ -240,7 +245,7 @@ const support = await getEmbeddedPostgresTestSupport(); const [retainedWorkspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId)); expect(retainedWorkspace.metadata?._issuePrivacySources).toMatchObject({ [privateSource.id]: true }); // Reauthorization also applies after the second inventory has a durable pin. - await expect(service.prepareForAdmission(admission)).rejects.toThrow(/outside this actor's access/); + await expect(prepareForAdmission(admission)).rejects.toThrow(/outside this actor's access/); expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n"); }); it("stages a root change without replacing active files or adding a wake", async () => { diff --git a/server/src/__tests__/task-workspace-repositories-trust.test.ts b/server/src/__tests__/task-workspace-repositories-trust.test.ts new file mode 100644 index 00000000000..0e9ec6968b0 --- /dev/null +++ b/server/src/__tests__/task-workspace-repositories-trust.test.ts @@ -0,0 +1,198 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositories, executionWorkspaces, + heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db"; +import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js"; +import { buildGitAuthInvocation } from "../services/git-credentials.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const acquisition = vi.hoisted(() => ({ managedAuth: vi.fn(), materialize: vi.fn(), catalog: vi.fn() })); +vi.mock("../services/tool-access.js", () => ({ toolAccessService: () => ({ listProjectRepositories: acquisition.catalog }) })); +vi.mock("../services/git-credentials.js", async (original) => ({ + ...await original(), + createGitRemoteAuthProvider: () => acquisition.managedAuth, +})); +vi.mock("../services/managed-repository-checkout.js", async (original) => ({ + ...await original(), + materializeManagedProjectWorkspace: acquisition.materialize, +})); + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)("task repository trust authority", () => { + let database: Awaited>; + let db: ReturnType; + let root: string; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("paperclip-repository-trust-"); + db = createDb(database.connectionString); + root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-trust-")); + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + afterEach(() => { vi.clearAllMocks(); }); + afterAll(async () => { await database?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); }); + + async function fixture() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(); + const userId = randomUUID(), cwd = path.join(root, issueId); + await mkdir(cwd); + await db.insert(companies).values({ id: companyId, name: "Repository trust", issuePrefix: `R${companyId.slice(0, 6).toUpperCase()}` }); + await db.insert(authUsers).values({ id: userId, name: "Sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(agents).values({ id: agentId, companyId, name: "Chat worker", adapterType: "process", status: "idle" }); + await db.insert(companyMemberships).values([ + { companyId, principalType: "user", principalId: userId, membershipRole: "operator", status: "active" }, + { companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" }, + ]); + const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Task files", cwd, + mode: "isolated_workspace", strategyType: "task_directory" }).returning(); + await db.insert(issues).values({ id: issueId, companyId, title: "Unlinked chat work", assigneeAgentId: agentId, + executionWorkspaceId: workspace.id }); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId: userId, + contextSnapshot: { issueId } }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + acquisition.catalog.mockResolvedValue({ repositories: [{ id: "private-repo", fullName: "sponsor/private", private: true, + url: "https://github.com/sponsor/private", connections: ["sponsor-github"] }] }); + acquisition.managedAuth.mockRejectedValue(new Error("Managed credentials must not be acquired")); + const actor = { type: "agent" as const, source: "agent_jwt" as const, companyId, agentId, runId, onBehalfOfUserId: userId }; + const policy = { trustPreset: "low_trust_review" as const, authorizationPolicy: { + trustPreset: "low_trust_review" as const, trustBoundary: { mode: "low_trust_review" as const, companyId, rootIssueId: issueId }, + } }; + const service = executionWorkspaceRepositoryService(db); + const request = (repository: { kind: "catalog"; id: string } | { kind: "url"; url: string }, requestKey = randomUUID()) => + service.request({ companyId, issueId, actor, request: { repository, requestKey } }); + const admission = { companyId, issueId, workspaceId: workspace.id, cwd, agentId, runId, responsibleUserId: userId }; + return { companyId, agentId, issueId, runId, userId, workspace, policy, service, actor, request, admission }; + } + + it.each(["agent", "issue", "project", "workspace_source_project", "run"] as const)( + "rejects catalog acquisition from raw %s low-trust policy", async source => { + const f = await fixture(); + if (source === "agent") await db.update(agents).set({ permissions: f.policy }).where(eq(agents.id, f.agentId)); + if (source === "issue") await db.update(issues).set({ executionPolicy: f.policy }).where(eq(issues.id, f.issueId)); + if (source === "run") await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId)); + if (source === "project" || source === "workspace_source_project") { + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Restricted", executionWorkspacePolicy: f.policy }).returning(); + if (source === "project") await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issueId)); + else { + await db.update(projects).set({ executionWorkspacePolicy: { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, + trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, projectIds: [project.id] } } } }).where(eq(projects.id, project.id)); + await db.update(executionWorkspaces).set({ projectId: project.id, strategyType: "git_worktree" }).where(eq(executionWorkspaces.id, f.workspace.id)); + } + } + await expect(f.request({ kind: "catalog", id: "private-repo" })).rejects.toThrow(/anonymous public URLs only/); + expect(await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0); + expect(acquisition.managedAuth).not.toHaveBeenCalled(); + }, + ); + + it("keeps a URL matching the sponsor's private catalog anonymous under retained run trust", async () => { + const f = await fixture(); + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId)); + const receipt = await f.request({ kind: "url", url: "https://github.com/sponsor/private" }); + expect(receipt.repository.catalogRepositoryId).toBeNull(); + acquisition.materialize.mockImplementationOnce(async (_cwd, input) => { + const auth = await input.resolveGitAuth(input.repoUrl); + expect(auth).toEqual({ ...buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null }), + env: expect.objectContaining({ GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }) }); + throw new Error("Anonymous remote rejects private repository"); + }); + await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow("Anonymous remote rejects private repository"); + expect(acquisition.materialize).toHaveBeenCalledOnce(); + expect(acquisition.managedAuth).not.toHaveBeenCalled(); + const [stored] = await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.id, receipt.operationId)); + expect(stored).toMatchObject({ catalogRepositoryId: null, state: "needs_repair" }); + }); + + it.each(["pending", "ready"] as const)("blocks a %s managed receipt after the run becomes low trust", async state => { + const f = await fixture(); + const receipt = await f.request({ kind: "catalog", id: "private-repo" }); + await db.update(executionWorkspaceRepositories).set({ state, pinnedCommit: state === "ready" ? "a".repeat(40) : null }) + .where(eq(executionWorkspaceRepositories.id, receipt.operationId)); + const checkout = path.join(f.admission.cwd, receipt.repository.relativePath); + if (state === "ready") { await mkdir(checkout, { recursive: true }); await writeFile(path.join(checkout, "private.txt"), "retained private bytes"); } + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId)); + await expect(f.request({ kind: "url", url: receipt.repository.repoUrl })).rejects.toThrow(/anonymous public URLs only/); + await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/anonymous public URLs only/); + expect(acquisition.materialize).not.toHaveBeenCalled(); + expect(acquisition.managedAuth).not.toHaveBeenCalled(); + if (state === "ready") expect(await readFile(path.join(checkout, "private.txt"), "utf8")).toBe("retained private bytes"); + }); + + it("prepares an anonymous public URL through actual local Git materialization", async () => { + const f = await fixture(); + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId)); + const receipt = await f.request({ kind: "url", url: "https://github.com/public/example" }); + const source = path.join(root, `${f.issueId}-public-source`); + await mkdir(source); + const exec = promisify(execFileCallback); + await exec("git", ["init", "--initial-branch=main", source]); + await writeFile(path.join(source, "public.txt"), "Public fixture bytes\n"); + await exec("git", ["-C", source, "add", "."]); + await exec("git", ["-C", source, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "public source"]); + const actual = await vi.importActual("../services/managed-repository-checkout.js"); + acquisition.materialize.mockImplementationOnce(async (cwd, input) => { + const auth = await input.resolveGitAuth(input.repoUrl); + expect(auth.env).toMatchObject({ GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null" }); + expect(auth.configArgs).toEqual(buildGitAuthInvocation({ token: "", source: "managed_connection", secretName: null }).configArgs); + const result = await actual.materializeManagedProjectWorkspace(cwd, { ...input, localSource: source }); + await exec("git", ["-C", cwd, "remote", "set-url", "origin", input.repoUrl]); + return result; + }); + const [prepared] = await f.service.prepareForAdmission(f.admission); + expect(prepared.id).toBe(receipt.operationId); + expect(await readFile(path.join(prepared.cwd, "public.txt"), "utf8")).toBe("Public fixture bytes\n"); + expect(acquisition.managedAuth).not.toHaveBeenCalled(); + }); + + it.each(["same_repository", "different_repository", "empty_inventory", "unpinned_publication"] as const)( + "rejects managed peer bytes in the same physical root with %s", async scenario => { + const f = await fixture(); + const managed = await f.request({ kind: "catalog", id: "private-repo" }); + const checkout = path.join(f.admission.cwd, managed.repository.relativePath); + await mkdir(path.join(checkout, ".git"), { recursive: true }); + await writeFile(path.join(checkout, "private.txt"), "Private peer bytes"); + if (scenario !== "unpinned_publication") await db.update(executionWorkspaceRepositories) + .set({ state: "ready", pinnedCommit: "b".repeat(40) }).where(eq(executionWorkspaceRepositories.id, managed.operationId)); + const [peer] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Same physical folder", + cwd: f.admission.cwd, mode: "isolated_workspace", strategyType: "task_directory" }).returning(); + const issueId = randomUUID(), runId = randomUUID(); + await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Low-trust peer", assigneeAgentId: f.agentId, + executionWorkspaceId: peer.id }); + const policy = { ...f.policy, authorizationPolicy: { ...f.policy.authorizationPolicy, + trustBoundary: { ...f.policy.authorizationPolicy.trustBoundary, rootIssueId: issueId } } }; + await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "running", + responsibleUserId: f.userId, contextSnapshot: { issueId, executionPolicy: policy } }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + if (scenario !== "empty_inventory") await f.service.request({ companyId: f.companyId, issueId, actor: { ...f.actor, runId }, + request: { requestKey: randomUUID(), repository: { kind: "url", url: scenario === "same_repository" ? managed.repository.repoUrl : "https://github.com/public/other" } } }); + await expect(f.service.prepareForAdmission({ ...f.admission, issueId, runId, workspaceId: peer.id })).rejects.toThrow(/anonymous public URLs only/); + expect(acquisition.materialize).not.toHaveBeenCalled(); + expect(acquisition.managedAuth).not.toHaveBeenCalled(); + expect(await readFile(path.join(checkout, "private.txt"), "utf8")).toBe("Private peer bytes"); + }, + ); + + it("retains previous run trust without confusing a board request's reassigned agent", async () => { + const f = await fixture(); + const [replacement] = await db.insert(agents).values({ companyId: f.companyId, name: "Replacement", adapterType: "process" }).returning(); + await db.update(issues).set({ assigneeAgentId: replacement.id }).where(eq(issues.id, f.issueId)); + await db.update(heartbeatRuns).set({ status: "succeeded", contextSnapshot: { taskId: f.issueId } }).where(eq(heartbeatRuns.id, f.runId)); + const request = () => f.service.request({ companyId: f.companyId, issueId: f.issueId, + actor: { type: "board", source: "local_implicit", userId: "local-board" }, + request: { requestKey: randomUUID(), repository: { kind: "catalog", id: "private-repo" } } }); + await expect(request()).resolves.toMatchObject({ kind: "requires_next_admission" }); + await db.update(heartbeatRuns).set({ contextSnapshot: { taskId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId)); + await expect(request()).rejects.toThrow(/anonymous public URLs only/); + }); + + it("rejects admission without the persisted task run before materialization", async () => { + const f = await fixture(); + await f.request({ kind: "catalog", id: "private-repo" }); + await expect(f.service.prepareForAdmission({ ...f.admission, runId: randomUUID() })).rejects.toThrow(/run identity/); + expect(acquisition.materialize).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index dd8fdb93bc5..6bb0714865e 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -4,13 +4,14 @@ import { promisify } from "node:util"; import fs from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import path from "node:path"; -import { and, eq, or, sql } from "drizzle-orm"; -import { activityLog, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db"; +import { and, eq, isNotNull, or, sql } from "drizzle-orm"; +import { activityLog, agents, heartbeatRuns, projects, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db"; import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared"; import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; import { conflict, forbidden, notFound } from "../errors.js"; import { assertTaskWorkspaceAccess } from "./task-workspace-source-access.js"; import { authorizationService, canActorReadExecutionWorkspace, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js"; +import { resolveCoreTrustPreset } from "./trust-preset-resolver.js"; import { captureRunIdentity } from "./run-identity.js"; import { toolAccessService } from "./tool-access.js"; import { normalizeProjectRepositoryUrl } from "./project-repositories.js"; @@ -19,6 +20,19 @@ import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } const execFile = promisify(execFileCallback); type RepositoryRow = typeof executionWorkspaceRepositories.$inferSelect; +type DbTransaction = Parameters[0]>[0]; + +const lowTrustRepositoryMessage = "Low-trust tasks may prepare anonymous public URLs only. Use an authorized configured source for private repositories."; + +function resolveAcquisitionSource(request: PrepareWorkspaceRepository["repository"], available: ProjectRepository[], anonymousOnly: boolean) { + if (anonymousOnly && request.kind === "catalog") throw forbidden(lowTrustRepositoryMessage); + // A sponsor's catalog is credential discovery, not authority for untrusted work. + return resolveTaskRepository(request, anonymousOnly ? [] : available); +} + +function assertAcquisitionReceipt(row: Pick, anonymousOnly: boolean) { + if (anonymousOnly && row.catalogRepositoryId) throw forbidden(lowTrustRepositoryMessage); +} /** The catalog is refreshed on request and admission; saved IDs are not grants. */ export function resolveTaskRepository(request: PrepareWorkspaceRepository["repository"], available: ProjectRepository[]) { @@ -44,6 +58,26 @@ export function executionWorkspaceRepositoryService(db: Db) { async function catalog(companyId: string, userId: string | null, localTrusted: boolean) { return (await toolAccessService(db).listProjectRepositories(companyId, userId, localTrusted)).repositories; } + async function requiresAnonymousAcquisition(reader: Db | DbTransaction, input: { + issue: typeof issues.$inferSelect; workspace: typeof executionWorkspaces.$inferSelect; + agentId?: string | null; runId?: string | null; + }) { + const { issue, workspace } = input; + const agentId = input.agentId ?? issue.assigneeAgentId; + const [agent] = agentId ? await reader.select().from(agents).where(and(eq(agents.id, agentId), eq(agents.companyId, issue.companyId))) : []; + if (agentId && !agent) throw forbidden("Task repository agent is unavailable"); + const [project] = issue.projectId ? await reader.select().from(projects).where(and(eq(projects.id, issue.projectId), eq(projects.companyId, issue.companyId))) : []; + const [workspaceSourceProject] = workspace.projectId ? await reader.select().from(projects).where(and(eq(projects.id, workspace.projectId), eq(projects.companyId, issue.companyId))) : []; + const runId = input.runId ?? issue.executionRunId; + const [run] = runId ? await reader.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) : []; + if (runId && (!run || (input.runId && run.agentId !== agentId) || (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== issue.id)) { + throw forbidden("Task repository run identity is unavailable"); + } + const trust = resolveCoreTrustPreset({ companyId: issue.companyId, agent, project, workspaceSourceProject, issue, + run: run ? { companyId: run.companyId, executionPolicy: run.contextSnapshot?.executionPolicy } : null }); + if (trust.kind === "denied") throw forbidden(trust.detail); + return trust.kind === "low_trust_review"; + } async function request(input: { companyId: string; issueId: string; actor: AuthorizationActor; request: PrepareWorkspaceRepository }) { const parsed = prepareWorkspaceRepositorySchema.parse(input.request); const [requestedIssue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(db, input.actor))); @@ -63,7 +97,7 @@ export function executionWorkspaceRepositoryService(db: Db) { localTrusted = userId === "local-board"; if (!userId) throw forbidden("Repository access requires a responsible user"); } - const source = resolveTaskRepository(parsed.repository, await catalog(input.companyId, userId, localTrusted)); + const available = await catalog(input.companyId, userId, localTrusted); const requestedRef = parsed.ref ?? "HEAD"; const receipt = await db.transaction(async tx => { const [issue] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(tx, input.actor))).for("no key update"); @@ -77,8 +111,14 @@ export function executionWorkspaceRepositoryService(db: Db) { const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, issue.executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId))).for("no key update"); if (!workspace || workspace.status !== "active") throw conflict("Execution workspace is unavailable"); await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId }); + const anonymousOnly = await requiresAnonymousAcquisition(tx, { issue, workspace, + agentId: input.actor.agentId, runId: input.actor.runId }); + const source = resolveAcquisitionSource(parsed.repository, available, anonymousOnly); const existing = await tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`))); - for (const row of existing) assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef); + for (const row of existing) { + assertAcquisitionReceipt(row, anonymousOnly); + assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef); + } if (existing[0]) { const row = existing[0]; if (!row.requestKeys.includes(parsed.requestKey)) { @@ -99,7 +139,7 @@ export function executionWorkspaceRepositoryService(db: Db) { } /** Logical rows may deliberately name one physical folder. Reuse bytes only * after validating every published owner's retained privacy and source intent. */ - async function sharedPublicationPin(input: { issueId: string; row: RepositoryRow; root: string; actor: AuthorizationActor; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) { + async function sharedPublicationPin(input: { issueId: string; row: RepositoryRow; root: string; actor: AuthorizationActor; anonymousOnly: boolean; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) { const candidates = await db.select({ repository: executionWorkspaceRepositories, workspace: executionWorkspaces }) .from(executionWorkspaceRepositories).innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) .where(and(eq(executionWorkspaceRepositories.companyId, input.row.companyId), eq(executionWorkspaces.companyId, input.row.companyId), @@ -111,6 +151,7 @@ export function executionWorkspaceRepositoryService(db: Db) { // An intent that has not published anything cannot claim another task's checkout. if (!candidatePin || !/^[a-f0-9]{40,64}$/.test(candidatePin)) continue; if (await fs.realpath(workspace.cwd).catch(() => null) !== input.root) continue; + assertAcquisitionReceipt(repository, input.anonymousOnly); assertRepositoryRetryMatches(repository, input.row.repositoryIdentity, input.row.requestedRef); if (!(await canActorReadExecutionWorkspace(db, input.actor, workspace.id))) throw forbidden("The shared repository retains files from a workspace outside this actor's access"); await assertTaskWorkspaceAccess(db, input.actor, input.row.companyId, workspace.id, { write: true, issueId: input.issueId }); @@ -119,18 +160,43 @@ export function executionWorkspaceRepositoryService(db: Db) { } return pin; } + async function assertAnonymousPhysicalRoot(companyId: string, root: string) { + const publications = await db.select({ repository: executionWorkspaceRepositories, cwd: executionWorkspaces.cwd }) + .from(executionWorkspaceRepositories).innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId)) + .where(and(eq(executionWorkspaceRepositories.companyId, companyId), eq(executionWorkspaces.companyId, companyId), + isNotNull(executionWorkspaceRepositories.catalogRepositoryId))); + for (const { repository, cwd } of publications) { + if (!cwd || await fs.realpath(cwd).catch(() => null) !== root) continue; + if (repository.relativePath !== taskRepositoryRelativePath(repository.repositoryIdentity)) throw conflict("Repository preparation receipt does not match its source"); + // Include an interrupted publication whose database pin was not saved yet. + // Even another repository's files would be included in the root snapshot. + if (repository.pinnedCommit || await fs.lstat(path.join(root, repository.relativePath)).catch(() => null)) { + throw forbidden(lowTrustRepositoryMessage); + } + } + } async function prepareForAdmission(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string; responsibleUserId: string | null }) { const rows = await list(input.companyId, input.workspaceId); - if (!rows.length) return []; - const available = await catalog(input.companyId, input.responsibleUserId, input.responsibleUserId === "local-board"); - const resolveAuth = createGitRemoteAuthProvider(db, input.companyId, { issueId: input.issueId, heartbeatRunId: input.runId, agentId: input.agentId, responsibleUserId: input.responsibleUserId }); const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.companyId, agentId: input.agentId, runId: input.runId, // local-board is a server identity, not a membership record. Preserve the // agent's own authority rather than promoting it to a board actor. onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId }; await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true, issueId: input.issueId }); + const [issue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))); + const [workspace] = await db.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId))); + if (!issue || !workspace || issue.executionWorkspaceId !== workspace.id) throw forbidden("Task repository workspace binding is unavailable"); + const anonymousOnly = await requiresAnonymousAcquisition(db, { issue, workspace, agentId: input.agentId, runId: input.runId }); const root = await fs.realpath(input.cwd); + if (anonymousOnly) await assertAnonymousPhysicalRoot(input.companyId, root); + if (!rows.length) return []; + const available = anonymousOnly ? [] : await catalog(input.companyId, input.responsibleUserId, input.responsibleUserId === "local-board"); + const resolveAuth = createGitRemoteAuthProvider(db, input.companyId, { issueId: input.issueId, heartbeatRunId: input.runId, agentId: input.agentId, responsibleUserId: input.responsibleUserId }); + // Validate every receipt before reading or transferring any prepared bytes. + const sources = rows.map(row => { + assertAcquisitionReceipt(row, anonymousOnly); + return resolveAcquisitionSource(row.catalogRepositoryId ? { kind: "catalog", id: row.catalogRepositoryId } : { kind: "url", url: row.repoUrl }, available, anonymousOnly); + }); const repositoryRoot = path.join(root, ".paperclip-repositories"); await fs.mkdir(repositoryRoot, { recursive: true }); if ((await fs.lstat(repositoryRoot)).isSymbolicLink() || await fs.realpath(repositoryRoot) !== repositoryRoot) throw conflict("Repository directory escapes the task workspace"); @@ -164,8 +230,8 @@ export function executionWorkspaceRepositoryService(db: Db) { } finally { await fs.rm(ignoreTemporary, { force: true }); } await ensureManagedRepositoriesIgnored(root); const prepared: Array<{ id: string; cwd: string; repoUrl: string; relativePath: string; pinnedCommit: string; branchName: string | null }> = []; - for (const row of rows) { - const source = resolveTaskRepository(row.catalogRepositoryId ? { kind: "catalog", id: row.catalogRepositoryId } : { kind: "url", url: row.repoUrl }, available); + for (const [index, row] of rows.entries()) { + const source = sources[index]; if (source.repositoryIdentity !== row.repositoryIdentity || row.relativePath !== taskRepositoryRelativePath(row.repositoryIdentity)) throw conflict("Repository preparation receipt does not match its source"); const cwd = path.join(root, row.relativePath); await withDirectoryPublicationLock(cwd, async () => { @@ -194,7 +260,7 @@ export function executionWorkspaceRepositoryService(db: Db) { } catch { return null; } finally { await handle.close(); } }; const initialOwnership = await readOwnership(); - const authorizedPin = await sharedPublicationPin({ issueId: input.issueId, row, root, actor, ownership: initialOwnership }); + const authorizedPin = await sharedPublicationPin({ issueId: input.issueId, row, root, actor, anonymousOnly, ownership: initialOwnership }); if (existing && !authorizedPin && initialOwnership?.repositoryId !== row.id) { throw conflict("Repository directory has no matching preparation receipt; repair it before retrying"); } diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index 0e82f0d25d8..1e27ed25b64 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -21,6 +21,8 @@ import { agents, heartbeatRuns, nativeRunFinalizations, + environmentLeases, + workspaceOperations, issueComments, issueWorkProducts, issues, @@ -1290,6 +1292,51 @@ type WorkspaceOverviewIssueRow = WorkspaceOverviewLinkedIssue & { const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseReadiness); +/** A failed response can be settled without a committed task result. Its durable + * output and resource obligations still fence root changes, including after Stop. */ +async function hasPendingTaskWorkspaceRecovery(tx: DbTransaction, input: { + companyId: string; issueId: string; excludeRunId?: string; +}) { + const rows = await tx.select({ finalization: nativeRunFinalizations, run: heartbeatRuns }) + .from(nativeRunFinalizations).innerJoin(heartbeatRuns, and( + eq(heartbeatRuns.companyId, nativeRunFinalizations.companyId), eq(heartbeatRuns.id, nativeRunFinalizations.runId), + )).where(and(eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId), + input.excludeRunId ? ne(nativeRunFinalizations.runId, input.excludeRunId) : undefined)); + if (rows.length === 0) return false; + if (rows.some(({ finalization, run }) => finalization.leaseOwner || + !["committed", "terminal_failure"].includes(finalization.phase) || + !["succeeded", "interrupted", "failed", "cancelled", "timed_out"].includes(run.status))) return true; + const runIds = rows.map(({ run }) => run.id); + const [pendingLease] = await tx.select({ id: environmentLeases.id }).from(environmentLeases).where(and( + eq(environmentLeases.companyId, input.companyId), inArray(environmentLeases.heartbeatRunId, runIds), + or(and(isNull(environmentLeases.releasedAt), + // A successful warm release settles the old run while keeping its resource. + sql`not coalesce(${environmentLeases.status} = 'retained' + and ${environmentLeases.leasePolicy} = 'reuse_by_environment' + and ${environmentLeases.cleanupStatus} = 'success', false)`), + eq(environmentLeases.status, "pending_cleanup"), eq(environmentLeases.cleanupStatus, "failed"), + sql`${environmentLeases.metadata} ? 'nativeWorkspaceExportResume'`), + )).limit(1); + if (pendingLease) return true; + const operations = await tx.select({ runId: workspaceOperations.heartbeatRunId, status: workspaceOperations.status }) + .from(workspaceOperations).where(and(eq(workspaceOperations.companyId, input.companyId), + inArray(workspaceOperations.heartbeatRunId, runIds), eq(workspaceOperations.phase, "workspace_finalize"))) + .orderBy(desc(workspaceOperations.createdAt), + // Equal timestamps cannot prove that success settled another attempt. + asc(sql`case when ${workspaceOperations.status} = 'succeeded' then 1 else 0 end`)); + const latest = new Map(); + for (const operation of operations) { + if (operation.runId && !latest.has(operation.runId)) latest.set(operation.runId, operation.status); + } + return rows.some(({ finalization, run }) => { + const status = latest.get(run.id); + if (status !== undefined) return status !== "succeeded"; + // Accepted output needs its persistence receipt, including terminal export + // repair. A successful unsafe-export omission is also a settled receipt. + return finalization.phase === "terminal_failure" && finalization.resultId !== null; + }); +} + export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) { /** Resolve prospective source use from the same deterministic inputs as admission. */ async function resolveSourceWorkspaceStrategy(input: { @@ -1417,14 +1464,12 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic if (pending.expectedBindingRevision !== task.workspaceBindingRevision) throw conflict("Pending workspace selection is stale"); const [holder] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( eq(heartbeatRuns.companyId, input.companyId), ne(heartbeatRuns.id, input.runId), - sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${input.issueId}`, + or(eq(heartbeatRuns.nativeIssueId, input.issueId), + sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${input.issueId}`), eq(heartbeatRuns.status, "running"), )).limit(1); if (holder) throw conflict("Previous task run must finish persistence before changing its workspace"); - const [unsettled] = await tx.select({ runId: nativeRunFinalizations.runId }).from(nativeRunFinalizations).where(and( - eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId), - ne(nativeRunFinalizations.runId, input.runId), ne(nativeRunFinalizations.phase, "committed"), - )).limit(1); + const unsettled = await hasPendingTaskWorkspaceRecovery(tx, { ...input, excludeRunId: input.runId }); if (unsettled) throw conflict("Previous task run still owns workspace recovery; finish recovery before changing its workspace"); const patch = await validateSelection({ ...input, selection: pending.intent.selection }, tx); await tx.update(issues).set({ ...patch, workspaceSelection: pending.intent, workspacePendingSelection: null, @@ -1497,10 +1542,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic sql`coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId') = ${input.issueId}`, task.executionRunId ? eq(heartbeatRuns.id, task.executionRunId) : undefined), )).limit(1); - const [unsettled] = await tx.select({ runId: nativeRunFinalizations.runId }).from(nativeRunFinalizations).where(and( - eq(nativeRunFinalizations.companyId, input.companyId), eq(nativeRunFinalizations.issueId, input.issueId), - ne(nativeRunFinalizations.phase, "committed"), - )).limit(1); + const unsettled = await hasPendingTaskWorkspaceRecovery(tx, input); if (holder || unsettled) throw conflict("Task run still owns its workspace; schedule a workspace selection for the next admission"); const [selectedWorkspace] = input.patch.executionWorkspaceId ? await tx.select({ projectWorkspaceId: executionWorkspaces.projectWorkspaceId }).from(executionWorkspaces).where(and(