fix(workspaces): retain local copy privacy and restore worktree proofs

Verify registered worktrees during preparation so upgrades and interrupted
admissions can resume without granting writes to ordinary occupied folders.
Authorize every configured local copy and preserve source permissions on
staging and task roots, including URI-only sources and closed-worktree reopen.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 14:49:01 -05:00
1 parent 4c220a0c04
commit 089d2048ae
14 files changed
+894 -113

No files matched your search

+1 -1
View File
@@ -753,6 +753,6 @@ Migration [0335_purple_kate_bishop.sql](../packages/db/src/migrations/0335_purpl
Migration [0336_colorful_kate_bishop.sql](../packages/db/src/migrations/0336_colorful_kate_bishop.sql) adds internal `execution_workspace_repository_roots` records. A company and hash of its canonical physical path uniquely identify a sticky `anonymous_only` or `managed` acquisition reservation. Short transactions serialize reservations with repository requests and credential promotion, preventing private publication after low-trust admission checks. Records have no workspace foreign key: mutable workspace metadata, relocation, archival or deletion cannot release a physical folder still accessible to an admitted run. There is no automatic mode change or reservation deletion.
Migration [0337_rapid_jamie_braddock.sql](../packages/db/src/migrations/0337_rapid_jamie_braddock.sql) adds internal `execution_workspace_repository_access` provenance. Company/canonical-root source entries retain execution-workspace and source-project identities; reader entries retain the exact admitted run, task, agent and responsible user. New repository receipts also record their root at creation; legacy receipts receive no inferred origin. Shared project-source entries retain which physical owners require workspace write authority. Current admission, selection, requests and publication check the current task’s writer against those owners; retained past readers and file APIs still require only read authority. Genuine isolated copies retain source read access without requiring shared-folder write access. The shared root fence checks every reader against every source before admission and future publication, including tasks without repository requests. File APIs apply the same source privacy without writing provenance. These entries grant no authority and have no workspace or run deletion cascade; unavailable retained identity fails closed. Automatic provenance release is deferred, so a newly incompatible permission scope requires a separate physical root.
Migration [0337_rapid_jamie_braddock.sql](../packages/db/src/migrations/0337_rapid_jamie_braddock.sql) adds internal `execution_workspace_repository_access` provenance. Company/canonical-root source entries retain execution-workspace and source-project identities; reader entries retain the exact admitted run, task, agent and responsible user. New repository receipts also record their root at creation; legacy receipts receive no inferred origin. Shared project-source entries retain which physical owners require workspace write authority. Current admission, selection, requests and publication check the current task’s writer against those owners; retained past readers and file APIs still require only read authority. Genuine isolated copies retain source read access without requiring shared-folder write access. An existing linked worktree can recover a missing isolated-copy proof only after Git registration and its captured source owner are verified. Configured local sibling copies retain deny-only task/project source ownership on the bound task root before materialization; ordinary repository clones create no isolation proof. The shared root fence checks every reader against every source before admission and future publication, including tasks without repository requests. File APIs apply the same source privacy without writing provenance. These entries grant no authority and have no workspace or run deletion cascade; unavailable retained identity fails closed. Automatic provenance release is deferred, so a newly incompatible permission scope requires a separate physical root.
No migration scans or copies existing agent home directories. Keep admitted native descriptors and recovery references until compatible controllers finish their persistence obligations. See [the architecture rollout contract](plans/2026-10-09-task-workspaces.md).
+2 -2
View File
@@ -43,9 +43,9 @@ Admission with no repository requests remains heartbeat-owned and requires no ad
Independent acquisition reserves a company-scoped canonical physical root as either `anonymous_only` or `managed` in an internal durable record. A short database fence serializes these reservations with repository requests and credential promotion. Low-trust use rejects every managed intent, including a clone still in staging, and reserves anonymous use before capture. Future managed requests cannot publish into that reserved root. The reservation survives controller restart, workspace aliases, metadata replacement, relocation and archival; changing the acquisition mode requires a separate physical root. No database lock remains held through cloning, input capture or provider execution. Anonymous admission also checks the readable candidate repository namespace against company-managed receipt identities, including on an existing reservation. A published identity collision is denied even when the old receipt path is unreadable; mutable ownership markers cannot authorize it. This conservatively requires a fresh root when anonymous and managed publications share an identity. Reservations also compare retained overlapping parent/child roots; component-boundary siblings remain independent. Initial reservations compare legacy managed intents through accessible canonical ancestors, conservatively rejecting overlapping inaccessible paths while unavailable unrelated historical folders do not block admission. Arbitrary external file writers remain subject to the existing shared-folder constraints.
Shared physical roots also retain source workspace and admitted reader identities separately from acquisition mode. Empty inventories do not bypass peer privacy: admission and recovery record the reader; requests and publication must remain readable to every retained reader. Admission registers the root before preparing configured sibling repositories, whose publication callback rechecks the intersection after staging. The fence uses authoritative workspace/run/task identities and current source privacy, never writable Git ownership markers. Ordinary heartbeat-owned admission to the same task's sole source adds no repository-mutation requirement, while any peer source or task requires the full read intersection. Missing retained identity fails closed; automatic release is deferred. Current writers must also satisfy `project:write_workspace` for every shared physical source project, including configured aliases with no execution row. Sharing classification is retained separately from read provenance and survives pointer changes. Selection checks without registering a reader; admission and publication independently recheck current writer authority. Proven isolated worktree copies require source read authority; past readers never receive an added mutation requirement.
Shared physical roots also retain source workspace and admitted reader identities separately from acquisition mode. Empty inventories do not bypass peer privacy: admission and recovery record the reader; requests and publication must remain readable to every retained reader. Admission registers the root before preparing configured sibling repositories. Each local sibling source is authorized before materialization, including local file URLs without a configured cwd and reuse of an existing sibling checkout. File-URL-only sources keep ordinary clone semantics; their source permissions still follow the copied files. Its captured task and project owners are retained on the bound task root before copying, so staging, published and detached files keep source privacy. A publication callback rechecks source permissions and every retained destination reader. Ordinary sibling clones add only read restrictions; they never create a worktree-isolation proof. The fence uses authoritative workspace/run/task identities and current source privacy, never writable Git ownership markers. Ordinary heartbeat-owned admission to the same task's sole source adds no repository-mutation requirement, while any peer source or task requires the full read intersection. Missing retained identity fails closed; automatic release is deferred. Current writers must also satisfy `project:write_workspace` for every shared physical source project, including configured aliases with no execution row. Sharing classification is retained separately from read provenance and survives pointer changes. Selection checks without registering a reader; admission and publication independently recheck current writer authority. Proven isolated worktree copies require source read authority; past readers never receive an added mutation requirement.
Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. Physical-source authority includes canonical ancestor and descendant overlaps, current configured/execution roots, and retained source/readers. Every admission/publication rechecks current roots even after a reader has been recorded. Configured workspace move/removal and project removal preserve source provenance before their first admission too. File listing, previews and downloads use the same read-only physical-source resolution. Those reads create no policy or reader records. Git worktree preparation checks actual checkout and common-repository source roots before copying. Server-owned captured source identities are retained on the isolated target before Git/provisioning and rechecked before exposure. The isolated_source and isolated_project_source text kinds preserve task/project privacy before any copying. They are deny-only intent, not proof of isolation. The separate isolated_copy proof is recorded only after Git validates a registered linked worktree; the ancestor-source write exemption also requires the target to remain a real worktree. Preparation may temporarily allow a validated missing/empty target, without persisting that exemption. Existing ordinary folders and exact/descendant target owners keep their write requirements. Deny-only copy provenance survives permission revocation or source deletion during preparation, including failed copies. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults.
Repository identity alone does not prove that a workspace published into a particular folder. Available unrelated copies remain independent; retained sources and ambiguous unavailable legacy publishers still constrain access. Physical-source authority includes canonical ancestor and descendant overlaps, current configured/execution roots, and retained source/readers. Every admission/publication rechecks current roots even after a reader has been recorded. Configured workspace move/removal and project removal preserve source provenance before their first admission too. Valid local file URLs and absolute repository paths also identify physical configured owners when no usable cwd exists. The same source resolver protects copying, file reads and configuration detachment. File listing, previews and downloads use the same read-only physical-source resolution. Those reads create no policy or reader records. Git worktree preparation checks actual checkout and common-repository source roots before copying. Server-owned captured source identities are retained on the isolated target before Git/provisioning and rechecked before exposure. The isolated_source and isolated_project_source text kinds preserve task/project privacy before any copying. They are deny-only intent, not proof of isolation. The separate isolated_copy proof is recorded only after Git validates a registered linked worktree; the ancestor-source write exemption also requires the target to remain a real worktree. Preparation may temporarily allow a validated missing/empty target, without persisting that exemption. An intact existing linked worktree without a proof record is first verified against the captured source and Git registration. Only that verification can establish its proof and let it resume after an upgrade or interrupted preparation; occupied ordinary folders remain strict. Existing ordinary folders and exact/descendant target owners keep their write requirements. Deny-only copy provenance survives permission revocation or source deletion during preparation, including failed copies. Managed local sources used by main or referenced projects receive the same checks and committed staging ownership before Git reads bytes. Closed-worktree reopening commits source ownership before its lifecycle transaction, uses read-only checks under that lock, and retains completed-copy evidence after releasing it. Source and destination identities must remain unchanged; no company root fence is acquired while the lifecycle lock is held. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults.
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.
@@ -1,14 +1,19 @@
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { mkdtemp, rm, stat } from "node:fs/promises";
import { mkdtemp, rm, stat, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { assertExecutionWorkspaceRootReadAccess } from "../services/execution-workspace-repositories.js";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
activityLog,
authUsers,
companyMemberships,
issueAccessGrants,
companies,
createDb,
executionWorkspaces,
@@ -57,7 +62,9 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(companyMemberships);
await db.delete(companies);
await db.delete(authUsers);
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
@@ -410,6 +417,54 @@ describeEmbeddedPostgres("reopen archived isolated execution workspace", () => {
}
});
it.each([false, true])("guards a private local managed reopen before Git and retains privacy after provisioning failure=%s", async provisionFails => {
const previousHome = process.env.PAPERCLIP_HOME;
const previousPrivacy = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
const home = await makeExistingDir();
process.env.PAPERCLIP_HOME = home;
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
try {
const { companyId, projectId, projectWorkspaceId } = await seedManagedCheckoutProject();
const readerId = randomUUID();
await db.insert(authUsers).values({ id: readerId, name: "Reader", email: `${readerId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: readerId, status: "active", membershipRole: "member" });
const authorizationActor = { type: "board" as const, source: "session" as const, userId: readerId, companyIds: [companyId], isInstanceAdmin: false };
const source = join(home, "private-source"), target = join(home, "worktree-copy");
initManagedGitRepo(source, "reopen-feature");
writeFileSync(join(source, "private.txt"), "PRIVATE_REOPEN_BYTES");
execFileSync("git", ["add", "private.txt"], { cwd: source });
execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "private"], { cwd: source });
execFileSync("git", ["branch", "-f", "reopen-feature", "HEAD"], { cwd: source });
const repoUrl = pathToFileURL(source).href;
await db.update(projectWorkspaces).set({ repoUrl }).where(eq(projectWorkspaces.id, projectWorkspaceId));
const privateIssueId = randomUUID();
await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private source", visibility: "private", status: "todo", responsibleUserId: "another-owner" });
await db.insert(executionWorkspaces).values({ companyId, projectId: null, sourceIssueId: privateIssueId, name: "Private owner", mode: "shared_workspace", strategyType: "project_primary", cwd: source, providerType: "local_fs", status: "active" });
const workspaceId = await seedClosedGitWorktreeWorkspace({ companyId, projectId, projectWorkspaceId, cwd: target, repoUrl, branchName: "reopen-feature" });
if (provisionFails) await db.update(executionWorkspaces).set({ metadata: { [EXECUTION_WORKSPACE_LIFECYCLE_GENERATION_METADATA_KEY]: 1, config: { provisionCommand: "exit 42" } } }).where(eq(executionWorkspaces.id, workspaceId));
const issueId = await seedIssue({ companyId, projectId, workspaceId, issueNumber: 4901 });
const reopen = () => executionWorkspaceService(db).reopenClosedIsolatedExecutionWorkspaceForIssue({ workspaceId, issue: { id: issueId, companyId, projectId }, actor: { actorType: "user", agentId: null }, authorizationActor });
expect(await reopen()).toMatchObject({ ok: false, code: "rebuild_failed" });
await expect(stat(target)).rejects.toMatchObject({ code: "ENOENT" });
const managed = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "private-source" });
await expect(stat(managed)).rejects.toMatchObject({ code: "ENOENT" });
await db.insert(issueAccessGrants).values({ issueId: privateIssueId, subjectType: "user", subjectId: readerId, source: "explicit" });
const results = provisionFails ? [await reopen()] : await Promise.all([reopen(), reopen()]);
expect(results.every(result => result.ok)).toBe(!provisionFails);
if (!provisionFails) expect(results.map(result => result.ok && result.reopened).sort()).toEqual([false, true]);
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: target, actor: authorizationActor })).resolves.toBeUndefined();
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, projectWorkspaceId));
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
await rm(source, { recursive: true, force: true });
for (const cwd of [managed, target]) await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd, actor: authorizationActor })).rejects.toMatchObject({ status: 403 });
expect(await readFile(join(target, "private.txt"), "utf8")).toBe("PRIVATE_REOPEN_BYTES");
} finally {
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
if (previousPrivacy === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE; else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacy;
}
}, 30_000);
it("refuses to reopen a workspace in another company", async () => {
const first = await seedCompanyProject();
const second = await seedCompanyProject();
@@ -3,21 +3,22 @@ import request from "supertest";
import { fileResourceRoutes } from "../routes/file-resources.js";
import { errorHandler } from "../middleware/index.js";
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { execFileSync } from "node:child_process";
import { mkdtemp, mkdir, writeFile, readFile, realpath, rm } from "node:fs/promises";
import { mkdtemp, mkdir, writeFile, readFile, realpath, readdir, rename, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, authUsers, companies, companyMemberships, createDb, environments, executionWorkspaces, heartbeatRuns, issues, principalPermissionGrants, projects, projectAccessMembers, projectWorkspaces } from "@paperclipai/db";
import { agents, authUsers, companies, companyMemberships, createDb, environments, executionWorkspaces, heartbeatRuns, issues, issueAccessGrants, principalPermissionGrants, projects, projectAccessMembers, projectWorkspaces } from "@paperclipai/db";
import { setExpensiveWorkspaceGitExecutor } from "@paperclipai/adapter-utils/git-workspace-sync";
import { buildProjectMentionHref } from "@paperclipai/shared";
import { createWorkspaceGitOperationScheduler, WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { heartbeatService } from "../services/heartbeat.ts";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { createHeartbeatWorkspaceResolver } from "../services/heartbeat/workspaces.js";
import { resolveManagedProjectWorkspaceDir, resolvePaperclipInstanceRoot } from "../home-paths.js";
import { accessService } from "../services/access.js";
import { assertExecutionWorkspaceRootWriteAccess } from "../services/execution-workspace-repositories.js";
import { instanceSettingsService } from "../services/instance-settings.ts";
@@ -745,6 +746,183 @@ suite("task project repository provisioning", () => {
expect(await readFile(path.join(source, "README.md"), "utf8")).toBe("preserved dirty work");
}, 40_000);
it.each(["fresh", "existing", "occupied", "file_url", "absolute_path"] as const)("authorizes a late private configured sibling before %s copy and retains its privacy", async (scenario) => {
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const sourceId = randomUUID(), siblingId = randomUUID(), privateIssueId = randomUUID();
const anchor = path.join(root, companyId, "anchor"), sibling = path.join(root, companyId, "private-sibling");
for (const cwd of [anchor, sibling]) {
await mkdir(cwd, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" });
await writeFile(path.join(cwd, "README.md"), "public committed content");
execFileSync("git", ["add", "."], { cwd, stdio: "ignore" });
execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed"], { cwd, stdio: "ignore" });
}
execFileSync("git", ["remote", "add", "origin", pathToFileURL(anchor).href], { cwd: anchor, stdio: "ignore" });
const urlOnly = scenario === "file_url" || scenario === "absolute_path";
await writeFile(path.join(sibling, "private.txt"), "PRIVATE_DIRTY_SIBLING_BYTES");
if (urlOnly) {
execFileSync("git", ["add", "private.txt"], { cwd: sibling, stdio: "ignore" });
execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "private source"], { cwd: sibling, stdio: "ignore" });
await writeFile(path.join(sibling, "uncommitted.txt"), "DO_NOT_OVERLAY_URL_ONLY_SOURCE");
}
await db.insert(companies).values({ id: companyId, name: "Late sibling", issuePrefix: "L" + companyId.slice(0, 6), defaultResponsibleUserId: "responsible-user" });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" });
await db.insert(projects).values({ id: projectId, companyId, name: "Read-only source", executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } });
await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId, name: "Primary", sourceType: "local_path", cwd: anchor, repoUrl: pathToFileURL(anchor).href, isPrimary: true });
await db.insert(agents).values({ id: agentId, companyId, name: "Reader", adapterType: "codex_local", status: "idle", adapterConfig: {}, permissions: {} });
await db.insert(issues).values({ id: issueId, companyId, title: "Keep task binding", status: "todo", assigneeAgentId: agentId,
projectWorkspaceId: sourceId, executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree" } },
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "managed_isolated" } } });
const admit = async () => {
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } });
expect(run).not.toBeNull();
await vi.waitFor(async () => expect((await heartbeat.getRun(run!.id))?.status).toMatch(/^(succeeded|failed)$/), { timeout: 15_000 });
await drainHeartbeatRunsToQuiescence(db, heartbeat);
return (await heartbeat.getRun(run!.id))!;
};
const initial = await admit();
expect(initial).toMatchObject({ status: "succeeded", error: null });
const binding = (await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId!;
const taskCwd = execute.mock.calls.find(([input]) => input.runId === initial.id)![0].context.paperclipWorkspace.cwd as string;
// The private local source appears only after the original task is bound.
await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private source task", status: "todo", visibility: "private", responsibleUserId: "private-owner" });
await db.insert(executionWorkspaces).values({ companyId, projectId: null, sourceIssueId: privateIssueId, name: "Private source files",
mode: "shared_workspace", strategyType: "project_primary", cwd: sibling, providerType: "local_fs", status: "active" });
const repoUrl = scenario === "absolute_path" ? sibling : pathToFileURL(sibling).href;
const configuredCwd = urlOnly ? null : sibling;
await db.insert(projectWorkspaces).values({ id: siblingId, companyId, projectId, name: "Late sibling", sourceType: "local_path", cwd: configuredCwd, repoUrl, isPrimary: false });
const key = (scenario === "absolute_path" ? "repo-" : "private-sibling-") + createHash("sha256").update(JSON.stringify([siblingId, repoUrl, null, configuredCwd])).digest("hex").slice(0, 12);
const copy = path.join(taskCwd, ".paperclip-repositories", key);
if (scenario === "existing" || scenario === "occupied") {
await mkdir(path.dirname(copy), { recursive: true });
if (scenario === "existing") execFileSync("git", ["clone", "--no-hardlinks", sibling, copy], { stdio: "ignore" });
else await mkdir(copy);
await writeFile(path.join(copy, "private.txt"), "PRESERVED_EXISTING_SIBLING_BYTES");
}
const denied = await admit();
expect(denied.status).toBe("failed");
expect(execute.mock.calls.filter(([input]) => input.runId === denied.id)).toHaveLength(0);
if (scenario === "fresh" || urlOnly) expect(await readdir(path.join(taskCwd, ".paperclip-repositories")).catch(() => [])).toEqual([]);
else expect(await readFile(path.join(copy, "private.txt"), "utf8")).toBe("PRESERVED_EXISTING_SIBLING_BYTES");
await db.insert(issueAccessGrants).values([
{ issueId: privateIssueId, subjectType: "agent", subjectId: agentId, source: "explicit" },
{ issueId: privateIssueId, subjectType: "user", subjectId: "responsible-user", source: "explicit" },
]);
const authorized = await admit();
expect(authorized.status).toBe(scenario === "occupied" ? "failed" : "succeeded");
expect(execute.mock.calls.filter(([input]) => input.runId === authorized.id)).toHaveLength(scenario === "occupied" ? 0 : 1);
if (scenario === "occupied") expect(authorized.error).toContain("not a git checkout");
const expectedBytes = scenario === "fresh" || urlOnly ? "PRIVATE_DIRTY_SIBLING_BYTES" : "PRESERVED_EXISTING_SIBLING_BYTES";
expect(await readFile(path.join(copy, "private.txt"), "utf8")).toBe(expectedBytes);
if (urlOnly) await expect(readFile(path.join(copy, "uncommitted.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId).toBe(binding);
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", userId: "responsible-user", companyIds: [companyId], source: "session", isInstanceAdmin: false }; next(); });
app.use("/api", fileResourceRoutes(db));
app.use(errorHandler);
const fileUrl = "/api/issues/" + issueId + "/file-resources/content";
const relativeFile = path.relative(taskCwd, path.join(copy, "private.txt"));
expect((await request(app).get(fileUrl).query({ workspace: "execution", path: relativeFile }).expect(200)).body.content.data).toBe(expectedBytes);
// Removing configuration must not erase permissions of already copied files.
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, siblingId));
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
for (const download of [false, true]) {
const response = await request(app).get(fileUrl).query({ workspace: "execution", path: relativeFile, download }).expect(404);
expect(JSON.stringify(response.body)).not.toContain(expectedBytes);
}
const deniedContinuation = await admit();
expect(deniedContinuation.status).toBe("failed");
expect(execute.mock.calls.filter(([input]) => input.runId === deniedContinuation.id)).toHaveLength(0);
expect(await readFile(path.join(copy, "private.txt"), "utf8")).toBe(expectedBytes);
} finally {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", previousPrivacyMode);
}
}, 60_000);
it("guards an unbound private file-URL anchor before cloning and retains copied source privacy", async () => {
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const sourceId = randomUUID(), privateIssueId = randomUUID();
const source = path.join(root, companyId, "private-anchor");
await mkdir(source, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: source, stdio: "ignore" });
await writeFile(path.join(source, "secret.txt"), "PRIVATE_ANCHOR_COMMITTED_BYTES");
execFileSync("git", ["add", "."], { cwd: source, stdio: "ignore" });
execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "private source"], { cwd: source, stdio: "ignore" });
await writeFile(path.join(source, "dirty.txt"), "DO_NOT_OVERLAY_URL_ONLY_ANCHOR");
await db.insert(companies).values({ id: companyId, name: "Private anchor", issuePrefix: "A" + companyId.slice(0, 6), defaultResponsibleUserId: "responsible-user" });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" });
await db.insert(projects).values({ id: projectId, companyId, name: "Public project" });
await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId, name: "URL-only anchor", sourceType: "git_repo", cwd: null, repoUrl: pathToFileURL(source).href, isPrimary: true });
await db.insert(agents).values({ id: agentId, companyId, name: "Reader", adapterType: "codex_local", status: "idle", adapterConfig: {}, permissions: {} });
await db.insert(issues).values({ id: privateIssueId, companyId, title: "Private original", visibility: "private", responsibleUserId: "private-owner" });
await db.insert(executionWorkspaces).values({ companyId, projectId: null, sourceIssueId: privateIssueId, name: "Private original files",
mode: "shared_workspace", strategyType: "project_primary", cwd: source, providerType: "local_fs", status: "active" });
await db.insert(issues).values({ id: issueId, companyId, title: "Read anchor", status: "todo", assigneeAgentId: agentId, projectWorkspaceId: sourceId,
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "shared" } } });
const [actualAgent] = await db.select().from(agents).where(eq(agents.id, agentId));
const [otherAgent] = await db.insert(agents).values({ companyId, name: "Other run owner", adapterType: "codex_local", status: "idle" }).returning();
const [foreignRun] = await db.insert(heartbeatRuns).values({ companyId, agentId: otherAgent.id, issueId, status: "failed", responsibleUserId: "responsible-user" }).returning();
const resolver = createHeartbeatWorkspaceResolver(db);
await expect(resolver.resolveWorkspaceForRun(actualAgent, { issueId, executionIdentityRunId: foreignRun.id }, null))
.rejects.toThrow(/authoritative run identity/);
await db.insert(authUsers).values({ id: "private-owner", name: "Private owner", email: "private-owner@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "private-owner", status: "active", membershipRole: "member" });
await db.insert(issueAccessGrants).values({ issueId: privateIssueId, subjectType: "agent", subjectId: agentId, source: "explicit" });
const [ownedRun] = await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "failed", responsibleUserId: "responsible-user" }).returning();
// A caller-supplied sponsor cannot replace the responsible user on the run.
await expect(resolver.resolveWorkspaceForRun(actualAgent, { issueId, executionIdentityRunId: ownedRun.id, responsibleUserId: "private-owner" }, null))
.rejects.toThrow(/access|source/i);
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
const admit = async () => {
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } });
expect(run).not.toBeNull();
await vi.waitFor(async () => expect((await heartbeat.getRun(run!.id))?.status).toMatch(/^(succeeded|failed)$/), { timeout: 15_000 });
await drainHeartbeatRunsToQuiescence(db, heartbeat);
return (await heartbeat.getRun(run!.id))!;
};
const denied = await admit();
expect(denied.status).toBe("failed");
expect(execute.mock.calls.filter(([input]) => input.runId === denied.id)).toHaveLength(0);
const managedCwd = resolveManagedProjectWorkspaceDir({ companyId, projectId, repoName: "private-anchor" });
await expect(readdir(managedCwd)).rejects.toMatchObject({ code: "ENOENT" });
await db.insert(issueAccessGrants).values([
{ issueId: privateIssueId, subjectType: "agent", subjectId: agentId, source: "explicit" },
{ issueId: privateIssueId, subjectType: "user", subjectId: "responsible-user", source: "explicit" },
]);
const authorized = await admit();
expect(authorized).toMatchObject({ status: "succeeded", error: null });
const copiedCwd = execute.mock.calls.find(([input]) => input.runId === authorized.id)![0].context.paperclipWorkspace.cwd;
expect(await realpath(copiedCwd)).toBe(await realpath(managedCwd));
expect(await readFile(path.join(copiedCwd, "secret.txt"), "utf8")).toBe("PRIVATE_ANCHOR_COMMITTED_BYTES");
await expect(readFile(path.join(copiedCwd, "dirty.txt"))).rejects.toMatchObject({ code: "ENOENT" });
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = { type: "board", userId: "responsible-user", companyIds: [companyId], source: "session", isInstanceAdmin: false }; next(); });
app.use("/api", fileResourceRoutes(db)); app.use(errorHandler);
const fileUrl = "/api/issues/" + issueId + "/file-resources/content";
expect((await request(app).get(fileUrl).query({ workspace: "execution", path: "secret.txt" }).expect(200)).body.content.data).toBe("PRIVATE_ANCHOR_COMMITTED_BYTES");
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
await expect(resolver.resolveWorkspaceForRun(actualAgent, { issueId, executionIdentityRunId: authorized.id, responsibleUserId: "private-owner" }, null))
.rejects.toThrow(/access|source/i);
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, sourceId));
await rename(source, source + "-removed");
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssueId));
for (const download of [false, true]) await request(app).get(fileUrl).query({ workspace: "execution", path: "secret.txt", download }).expect(404);
expect(await readFile(path.join(copiedCwd, "secret.txt"), "utf8")).toBe("PRIVATE_ANCHOR_COMMITTED_BYTES");
} finally {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", previousPrivacyMode);
}
}, 40_000);
it.each([1, 2])("gives a task all %i repositories without any configured local folders", async (count) => {
const companyId = randomUUID();
const projectId = randomUUID();
@@ -44,6 +44,23 @@ describe("atomic managed repository publication", () => {
expect(JSON.parse(await readFile(path.join(target, ".git", "paperclip-workspace-owner.json"), "utf8"))).toMatchObject({ repositoryId: "receipt", pinnedCommit: expect.stringMatching(/^[a-f0-9]{40}$/) });
expect(await readFile(path.join(target, "source.txt"), "utf8")).toBe("initial\n");
});
it("authorizes the empty staging root before copying any source bytes", async () => {
const { root, source, target } = await fixture();
let stagingChecked = false;
await expect(materializeManagedProjectWorkspace(target, {
repoUrl: source,
beforeCopy: async staging => {
expect(await readdir(staging)).toEqual([]);
expect(await stat(target).catch(() => null)).toBeNull();
stagingChecked = true;
throw new Error("Private staging source denied");
},
})).rejects.toThrow("Private staging source denied");
expect(stagingChecked).toBe(true);
expect(await stat(target).catch(() => null)).toBeNull();
expect((await readdir(root)).filter(name => name.startsWith("target.clone-"))).toEqual([]);
expect(await readFile(path.join(source, "source.txt"), "utf8")).toBe("initial\n");
});
it("does not publish an incomplete checkout when its receipt cannot be committed", async () => {
const { root, source, target } = await fixture();
await expect(materializeManagedProjectWorkspace(target, { repoUrl: "https://github.com/test/source", localSource: source, beforePublish: async () => { throw new Error("receipt failed"); } })).rejects.toThrow(/receipt failed/);
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, rm, writeFile, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
@@ -28,8 +29,9 @@ const support = await getEmbeddedPostgresTestSupport();
if (root) await rm(root, { recursive: true, force: true });
});
it.each(["move", "remove", "remove_project", "replace_repositories"] as const)(
"retains nested private files before first admission when configured source changes: %s", async (mutation) => {
it.each((["cwd", "file_url", "absolute_path"] as const).flatMap(sourceKind =>
(["move", "remove", "remove_project", "replace_repositories"] as const).map(mutation => ({ sourceKind, mutation }))))(
"retains nested private files before first admission ($sourceKind / $mutation)", async ({ sourceKind, mutation }) => {
const companyId = randomUUID(), userId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const cwd = path.join(root, companyId), privateCwd = path.join(cwd, "private"), unrelatedCwd = `${cwd}-unrelated`;
await mkdir(privateCwd, { recursive: true });
@@ -41,15 +43,19 @@ const support = await getEmbeddedPostgresTestSupport();
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" });
const [source] = await db.insert(projects).values({ companyId, name: "Private source", visibility: "private",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [configured] = await db.insert(projectWorkspaces).values({ companyId, projectId: source.id, name: "Private files", cwd: privateCwd,
repoUrl: "https://github.com/example/private", metadata: { githubRepositoryId: "fixture-private" } }).returning();
const sourceUrl = (cwd: string) => sourceKind === "file_url" ? pathToFileURL(cwd).href : cwd;
const [configured] = await db.insert(projectWorkspaces).values({ companyId, projectId: source.id, name: "Private files",
cwd: sourceKind === "cwd" ? privateCwd : null,
repoUrl: sourceKind === "cwd" ? "https://github.com/example/private" : sourceUrl(privateCwd),
metadata: { githubRepositoryId: "fixture-private" } }).returning();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Public parent", cwd, mode: "shared_workspace", strategyType: "task_directory" }).returning();
await db.insert(issues).values({ id: issueId, companyId, title: "Public task", assigneeAgentId: agentId, executionWorkspaceId: workspace.id });
const actor = { type: "board" as const, source: "session" as const, userId, ignoreInstanceAdmin: true };
const access = { companyId, cwd, sourceWorkspaceId: workspace.id, actor };
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId))).toEqual([]);
const service = projectService(db, { captureWorkspaceUpdates: false });
if (mutation === "move") await service.updateWorkspace(source.id, configured.id, { cwd: unrelatedCwd });
if (mutation === "move") await service.updateWorkspace(source.id, configured.id,
sourceKind === "cwd" ? { cwd: unrelatedCwd } : { repoUrl: sourceUrl(unrelatedCwd) });
else if (mutation === "remove") await service.removeWorkspace(source.id, configured.id);
else if (mutation === "remove_project") await service.remove(source.id);
else await service.replaceRepositories(source.id, []);
@@ -64,12 +70,15 @@ const support = await getEmbeddedPostgresTestSupport();
// The common-prefix sibling is physically independent of the old root.
if (mutation !== "move") await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: unrelatedCwd, actor })).resolves.toBeUndefined();
if (mutation === "remove_project") return; // Missing sticky owner must remain denied.
await db.insert(projectAccessMembers).values({ companyId, projectId: source.id, subjectType: "user", subjectId: userId });
const [readGrant] = await db.insert(projectAccessMembers).values({ companyId, projectId: source.id, subjectType: "user", subjectId: userId }).returning();
await expect(assertExecutionWorkspaceRootReadAccess(db, access)).resolves.toBeUndefined();
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/protected|not authorized/);
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId,
permissionKey: "tasks:assign_scope", scope: { projectId: source.id, assigneeAgentId: agentId } });
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).resolves.toBeUndefined();
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, readGrant.id));
await expect(assertExecutionWorkspaceRootReadAccess(db, access)).rejects.toThrow(/source project/);
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/source project/);
});
it("can remove a configured folder that never existed without inventing provenance", async () => {
@@ -11,6 +11,7 @@ import { agents, authUsers, companies, companyMemberships, createDb, executionWo
heartbeatRuns, issueAccessGrants, issues, projects, projectWorkspaces, projectAccessMembers, principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { assertExecutionWorkspaceRootReadAccess, assertExecutionWorkspaceRootWriteAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { projectService } from "../services/projects.js";
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
import { buildGitAuthInvocation } from "../services/git-credentials.js";
@@ -291,6 +292,182 @@ const support = await getEmbeddedPostgresTestSupport();
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, sourceWorkspaceId: target.id })).rejects.toThrow(/source project/);
});
it.each(["file_uri", "absolute_path", "materialized_copy"] as const)("authorizes URL-only private configured owners before a public local clone (%s)", async transport => {
const f = await fixture(), exec = promisify(execFileCallback);
const sourceCwd = path.join(root, `${f.issueId}-url-only-source`); await mkdir(sourceCwd);
await exec("git", ["init", "--initial-branch=main", sourceCwd]);
await writeFile(path.join(sourceCwd, "secret.txt"), "Private URL source\n");
await exec("git", ["-C", sourceCwd, "add", "."]);
await exec("git", ["-C", sourceCwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
const repoUrl = transport === "absolute_path" ? sourceCwd : `file://${sourceCwd}`;
const [privateProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Private URL owner", visibility: "private" }).returning();
const privateCopy = transport === "materialized_copy" ? path.join(root, `${f.issueId}-independent-private`) : null;
if (privateCopy) await exec("git", ["clone", repoUrl, privateCopy]);
const [privateSource] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: privateProject.id,
name: "URL-only private source", cwd: privateCopy, repoUrl }).returning();
const [publicProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Public URL selection" }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: publicProject.id, name: "Same URL", cwd: null, repoUrl });
await db.update(issues).set({ projectId: publicProject.id }).where(eq(issues.id, f.issueId));
const sourceInput = { companyId: f.companyId, sourceCwd, actor: f.actor, copyKind: "repository" as const };
if (privateCopy) {
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput);
expect(captured.projectIds).not.toContain(privateProject.id);
return; // A private materialized copy does not privatize its public origin.
}
await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/);
const before = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
expect(before).toEqual([]);
// Move the URL before any admission or copy registration: the mutation must
// preserve the old physical source policy even without execution rows.
await projectService(db, { captureWorkspaceUpdates: false }).updateWorkspace(privateProject.id, privateSource.id,
{ repoUrl: "https://github.com/example/moved" });
await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/);
await db.insert(projectAccessMembers).values([
{ companyId: f.companyId, projectId: privateProject.id, subjectType: "agent", subjectId: f.agentId },
{ companyId: f.companyId, projectId: privateProject.id, subjectType: "user", subjectId: f.userId },
]);
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput);
const targetCwd = path.join(root, `${f.issueId}-url-copy`);
await retainIsolatedWorkspaceSourceAccess(db, { ...sourceInput, targetCwd, captured, issueId: f.issueId });
await exec("git", ["clone", repoUrl, targetCwd]);
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, privateProject.id));
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor })).rejects.toThrow(/source project/);
expect(await readFile(path.join(targetCwd, "secret.txt"), "utf8")).toBe("Private URL source\n");
});
it.each(["checkout", "bare", "protected_ancestor"] as const)("guards unbound local repository staging and publication without worktree authority (%s)", async scenario => {
const f = await fixture(), exec = promisify(execFileCallback);
const checkout = path.join(root, `${f.issueId}-local-origin`); await mkdir(checkout);
await exec("git", ["init", "--initial-branch=main", checkout]);
await writeFile(path.join(checkout, "secret.txt"), "Committed private bytes\n");
await exec("git", ["-C", checkout, "add", "."]);
await exec("git", ["-C", checkout, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "private source"]);
const sourceCwd = scenario === "bare" ? path.join(root, `${f.issueId}-bare.git`) : checkout;
if (scenario === "bare") await exec("git", ["clone", "--bare", checkout, sourceCwd]);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private local origin", visibility: "private",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Local source owner", cwd: sourceCwd });
const sourceInput = { companyId: f.companyId, sourceCwd, actor: f.actor, copyKind: "repository" as const };
await expect(assertIsolatedWorkspaceSourceReadAccess(db, sourceInput)).rejects.toThrow(/source project/);
await db.insert(projectAccessMembers).values([
{ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId },
{ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId },
]);
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, sourceInput);
const stage = scenario === "protected_ancestor" ? path.join(sourceCwd, "clone-staging") : path.join(root, `${f.issueId}-staging`);
const input = { ...sourceInput, targetCwd: stage, captured, issueId: f.issueId, phase: "verified" as const };
if (scenario === "protected_ancestor") {
await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/protected|assignment grant/);
await expect(fs.lstat(stage)).rejects.toMatchObject({ code: "ENOENT" });
} else {
await retainIsolatedWorkspaceSourceAccess(db, input);
await exec("git", ["clone", sourceCwd, stage]);
const target = path.join(root, `${f.issueId}-published-local`);
await retainIsolatedWorkspaceSourceAccess(db, { ...input, targetCwd: target });
await fs.rename(stage, target);
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id));
await expect(retainIsolatedWorkspaceSourceAccess(db, { ...input, targetCwd: target })).rejects.toThrow(/source project/);
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: target, actor: f.actor })).rejects.toThrow(/source project/);
expect(await readFile(path.join(target, "secret.txt"), "utf8")).toBe("Committed private bytes\n");
}
const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
expect(entries.some(entry => entry.kind === "isolated_project_source" && entry.scopeId === project.id)).toBe(true);
expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false);
});
it.each(["url", "catalog"] as const)("rejects independent local-file acquisition through %s before a receipt or clone", async kind => {
const f = await fixture();
const url = `file://${f.admission.cwd}`;
if (kind === "catalog") acquisition.catalog.mockResolvedValue({ repositories: [{ id: "private-repo", fullName: "private/local", url, connections: ["fixture"] }] });
await expect(f.request(kind === "url" ? { kind, url } : { kind, id: "private-repo" })).rejects.toThrow(/HTTPS GitHub/);
expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]);
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it.each(["grant_revoked", "source_detached", "retained_reader"] as const)("retains configured sibling source privacy on the task root (%s)", async scenario => {
const f = await fixture(), exec = promisify(execFileCallback);
const sourceCwd = path.join(root, `${f.issueId}-configured-source`);
await mkdir(sourceCwd);
const privateCwd = path.join(sourceCwd, "private"); await mkdir(privateCwd);
await writeFile(path.join(privateCwd, "secret.txt"), "Private configured bytes\n");
await exec("git", ["init", "--initial-branch=main", sourceCwd]);
await exec("git", ["-C", sourceCwd, "add", "."]);
await exec("git", ["-C", sourceCwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private sibling source", visibility: "private",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [configuration] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private subtree", cwd: privateCwd }).returning();
const input = { ...f.admission, sourceCwd };
const peer = scenario === "retained_reader" ? await standardPeer(f) : null;
if (peer) await f.service.prepareForAdmission(peer.admission);
await expect(f.service.prepareConfiguredRepositorySource(input)).rejects.toThrow(/source project/);
await db.insert(projectAccessMembers).values([
{ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId },
{ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId },
]);
if (peer) {
await expect(f.service.prepareConfiguredRepositorySource(input)).rejects.toThrow(/source project/);
} else {
const beforePublish = await f.service.prepareConfiguredRepositorySource(input);
const destination = path.join(f.admission.cwd, ".paperclip-repositories", "configured-source");
await exec("git", ["clone", sourceCwd, destination]);
await expect(beforePublish()).resolves.toBeUndefined();
if (scenario === "source_detached") await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, configuration.id));
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id));
await expect(beforePublish()).rejects.toThrow(/source project/);
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, actor: f.actor })).rejects.toThrow(/source project/);
expect(await readFile(path.join(destination, "private", "secret.txt"), "utf8")).toBe("Private configured bytes\n");
}
const entries = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
expect(entries.some(entry => entry.kind === "isolated_project_source" && entry.scopeId === project.id)).toBe(true);
expect(entries.some(entry => entry.kind === "isolated_copy")).toBe(false);
expect(entries.filter(entry => entry.kind === "reader")).toHaveLength(peer ? 1 : 0);
});
it.each(["upgrade", "interrupted_creation", "unrelated_owner"] as const)("validates a registered dirty worktree without completed copy proof after %s", async scenario => {
const f = await fixture(), exec = promisify(execFileCallback);
const git = async (...args: string[]) => (await exec("git", ["-C", f.admission.cwd, ...args])).stdout;
await exec("git", ["init", "--initial-branch=main", f.admission.cwd]);
await writeFile(path.join(f.admission.cwd, "tracked.txt"), "Original source\n");
await git("add", ".");
await git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source");
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected legacy source", visibility: "private",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: f.admission.cwd });
await db.insert(projectAccessMembers).values([
{ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId },
{ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId },
]);
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor });
const targetCwd = path.join(f.admission.cwd, ".paperclip", "worktrees", "legacy");
const input = { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, issueId: f.issueId, captured };
if (scenario === "interrupted_creation") await retainIsolatedWorkspaceSourceAccess(db, input);
if (scenario === "unrelated_owner") {
const unrelated = path.join(root, `${f.issueId}-unrelated-git`);
await exec("git", ["init", "--initial-branch=main", unrelated]);
await exec("git", ["-C", unrelated, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--allow-empty", "-m", "unrelated"]);
await exec("git", ["-C", unrelated, "worktree", "add", "-b", "legacy", targetCwd]);
} else await git("worktree", "add", "-b", "legacy", targetCwd);
await writeFile(path.join(targetCwd, "tracked.txt"), "Keep dirty edit\n");
await writeFile(path.join(targetCwd, "untracked.txt"), "Keep untracked work\n");
const before = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
expect(before.some(entry => entry.kind === "isolated_copy")).toBe(false);
const gitState = await exec("git", ["-C", targetCwd, "status", "--porcelain"]);
const head = await exec("git", ["-C", targetCwd, "rev-parse", "HEAD"]);
if (scenario === "unrelated_owner") {
await expect(retainIsolatedWorkspaceSourceAccess(db, input)).rejects.toThrow(/protected|assignment grant/);
await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId })).rejects.toThrow(/protected|assignment grant/);
} else {
await expect(retainIsolatedWorkspaceSourceAccess(db, input)).resolves.toBeUndefined();
await expect(assertExecutionWorkspaceRootWriteAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, issueId: f.issueId })).resolves.toBeUndefined();
}
const after = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
expect(after.some(entry => entry.kind === "isolated_copy" && entry.scopeId === project.id)).toBe(scenario !== "unrelated_owner");
expect((await exec("git", ["-C", targetCwd, "status", "--porcelain"])).stdout).toBe(gitState.stdout);
expect((await exec("git", ["-C", targetCwd, "rev-parse", "HEAD"])).stdout).toBe(head.stdout);
expect(await readFile(path.join(targetCwd, "tracked.txt"), "utf8")).toBe("Keep dirty edit\n");
expect(await readFile(path.join(targetCwd, "untracked.txt"), "utf8")).toBe("Keep untracked work\n");
});
it("does not let an unmaterialized copy intent authorize writes to an existing source folder", async () => {
const f = await fixture(), exec = promisify(execFileCallback);
const targetCwd = path.join(f.admission.cwd, "tracked-private-folder");
@@ -17,6 +17,7 @@ import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js"
import { captureRunIdentity } from "./run-identity.js";
import { toolAccessService } from "./tool-access.js";
import { normalizeProjectRepositoryUrl } from "./project-repositories.js";
import { localRepositoryPath } from "./local-repository-path.js";
import { buildGitAuthInvocation, createGitRemoteAuthProvider } from "./git-credentials.js";
import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "./managed-repository-checkout.js";
@@ -25,7 +26,7 @@ type RepositoryRow = typeof executionWorkspaceRepositories.$inferSelect;
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
/** Match physical aliases, including task directories not materialized yet. */
async function canonicalRepositoryRoot(cwd: string): Promise<string> {
export async function canonicalRepositoryRoot(cwd: string): Promise<string> {
const absolute = path.resolve(cwd);
try { return await fs.realpath(absolute); }
catch (error) {
@@ -105,6 +106,34 @@ export function assertRepositoryRetryMatches(row: Pick<RepositoryRow, "repositor
}
}
/** A configured source can be an existing checkout or a local repository URL
* before its checkout exists. Use the same physical owner for reads and detach
* preservation; unrelated unavailable paths cannot block company-wide scans. */
async function configuredWorkspaceRoot(input: { cwd: string | null; repoUrl?: string | null }, scan: boolean): Promise<string | null> {
const resolveDirectory = async (cwd: string): Promise<string | null> => {
try {
const entry = await fs.stat(cwd);
if (!entry.isDirectory()) {
if (scan) return null;
throw forbidden("Workspace files cannot be detached from unverifiable provenance");
}
return await fs.realpath(cwd);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || (scan && ["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(code ?? ""))) return null;
throw error;
}
};
if (input.cwd) {
const cwd = await resolveDirectory(input.cwd);
if (cwd) return cwd;
}
let repositoryPath: string | null;
try { repositoryPath = input.repoUrl ? localRepositoryPath(input.repoUrl) : null; }
catch (error) { if (scan) return null; throw error; }
return repositoryPath ? resolveDirectory(repositoryPath) : null;
}
/** Resolve persisted and physical file provenance without registering readers or policies. */
async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
companyId: string; cwd: string; sourceWorkspaceId?: string; inspectCurrentAliases?: boolean; preparingCopyProjectIds?: Set<string>;
@@ -204,7 +233,7 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
if ([...publishedNames].some(name => !recognizedNames.has(name))) throw forbidden("Published repository has no retained workspace provenance");
// Configured folders can contain project files before any task has an
// execution workspace. Read access must include their current aliases too.
const configured = await tx.select({ projectId: projectWorkspaces.projectId, cwd: projectWorkspaces.cwd })
const configured = await tx.select({ projectId: projectWorkspaces.projectId, cwd: projectWorkspaces.cwd, repoUrl: projectWorkspaces.repoUrl })
.from(projectWorkspaces).where(eq(projectWorkspaces.companyId, input.companyId));
// Retain the privacy owner, not its disposable workspace configuration.
// Execution sources may later lose their project FK; retained files must not.
@@ -222,11 +251,8 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
}
}
for (const source of configured) {
if (!source.cwd || sharedProjectIds.has(source.projectId)) continue;
const sourceRoot = await fs.realpath(source.cwd).catch((error: NodeJS.ErrnoException) => {
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
throw error;
});
if (sharedProjectIds.has(source.projectId)) continue;
const sourceRoot = await configuredWorkspaceRoot(source, true);
if (!sourceRoot || !repositoryRootsOverlap(sourceRoot, canonicalCwd)) continue;
sourceProjectIds.add(source.projectId);
if (!isCopiedAncestor(source.projectId, sourceRoot)) sharedProjectIds.add(source.projectId);
@@ -251,7 +277,7 @@ async function assertRootProjectAccess(db: Db | DbTransaction, actor: Authorizat
}
}
async function retainedRootReader(tx: DbTransaction, companyId: string, reader: typeof executionWorkspaceRepositoryAccess.$inferSelect) {
async function retainedRootReader(tx: DbTransaction, companyId: string, reader: Pick<typeof executionWorkspaceRepositoryAccess.$inferSelect, "scopeId" | "issueId" | "agentId" | "responsibleUserId">) {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, reader.scopeId), eq(heartbeatRuns.companyId, companyId)));
const [agent] = reader.agentId ? await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, reader.agentId), eq(agents.companyId, companyId))) : [];
const [issue] = reader.issueId ? await tx.select().from(issues).where(and(eq(issues.id, reader.issueId), eq(issues.companyId, companyId))) : [];
@@ -274,16 +300,10 @@ export async function lockExecutionWorkspaceRootProvenance(tx: DbTransaction, co
* Preserve that ownership before its configuration can move or disappear.
* Caller holds the company root fence and the fresh configured-workspace lock. */
export async function preserveConfiguredProjectWorkspaceRootProvenance(tx: DbTransaction, input: {
companyId: string; projectId: string; cwd: string | null;
companyId: string; projectId: string; cwd: string | null; repoUrl?: string | null;
}): Promise<void> {
if (!input.cwd) return;
const directory = await fs.stat(input.cwd).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!directory) return;
if (!directory.isDirectory()) throw forbidden("Workspace files cannot be detached from unverifiable provenance");
const canonicalCwd = await canonicalRepositoryRoot(input.cwd);
const canonicalCwd = await configuredWorkspaceRoot(input, false);
if (!canonicalCwd) return;
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
const entries = await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
@@ -358,14 +378,20 @@ async function unusedIsolatedCopyTarget(cwd: string): Promise<boolean> {
/** Match realization's checkout and common repository roots. A configured cwd
* may be a subdirectory or linked worktree while Git copies a broader tree. */
async function isolatedWorkspaceSourceRoots(sourceCwd: string): Promise<string[]> {
type WorkspaceSourceCopyKind = "linked_worktree" | "repository";
async function isolatedWorkspaceSourceRoots(sourceCwd: string, copyKind: WorkspaceSourceCopyKind = "linked_worktree"): Promise<string[]> {
const git = async (cwd: string, argument: string) => (await execFile("git", ["-C", cwd, "rev-parse", argument], {
timeout: 30_000, env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
})).stdout.trim();
const localRoot = await fs.realpath(sourceCwd);
if (copyKind === "repository" && await git(sourceCwd, "--is-bare-repository") === "true") {
const gitRoot = await fs.realpath(path.resolve(sourceCwd, await git(sourceCwd, "--absolute-git-dir")));
return [...new Set([localRoot, gitRoot])];
}
const checkoutRoot = await fs.realpath(path.resolve(await git(sourceCwd, "--show-toplevel")));
const commonDir = await git(checkoutRoot, "--git-common-dir");
const ownerRoot = await fs.realpath(path.dirname(path.resolve(checkoutRoot, commonDir)));
return [...new Set([checkoutRoot, ownerRoot])];
return [...new Set(copyKind === "repository" ? [localRoot, checkoutRoot, ownerRoot] : [checkoutRoot, ownerRoot])];
}
/** Internal, deny-only snapshot captured before copying; never accepted from a model or API payload. */
@@ -377,9 +403,9 @@ export type IsolatedWorkspaceSourceAccess = {
};
export async function assertIsolatedWorkspaceSourceReadAccess(db: Db, input: {
companyId: string; sourceCwd: string; actor: AuthorizationActor;
companyId: string; sourceCwd: string; actor: AuthorizationActor; copyKind?: WorkspaceSourceCopyKind;
}): Promise<IsolatedWorkspaceSourceAccess> {
const roots = await isolatedWorkspaceSourceRoots(input.sourceCwd);
const roots = await isolatedWorkspaceSourceRoots(input.sourceCwd, input.copyKind);
return db.transaction(async tx => {
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
const workspaceIds = new Set<string>(), projectIds = new Set<string>();
@@ -401,12 +427,12 @@ export async function assertIsolatedWorkspaceSourceReadAccess(db: Db, input: {
* already-copied private bytes never become an unowned readable target. */
export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
companyId: string; sourceCwd: string; targetCwd: string; actor: AuthorizationActor;
captured: IsolatedWorkspaceSourceAccess; issueId?: string; phase?: "preparing" | "verified";
captured: IsolatedWorkspaceSourceAccess; issueId?: string; phase?: "preparing" | "verified"; copyKind?: WorkspaceSourceCopyKind;
}): Promise<void> {
if (input.captured.companyId !== input.companyId) throw forbidden("Isolated copy source identity changed");
let sourceFailure: unknown;
const roots = new Set(input.captured.roots);
try { for (const cwd of await isolatedWorkspaceSourceRoots(input.sourceCwd)) roots.add(cwd); }
try { for (const cwd of await isolatedWorkspaceSourceRoots(input.sourceCwd, input.copyKind)) roots.add(cwd); }
catch (error) { sourceFailure = error; }
const canonicalCwd = await canonicalRepositoryRoot(input.targetCwd);
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
@@ -447,12 +473,17 @@ export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
throw error;
});
if (targetEntry?.isSymbolicLink()) throw forbidden("Isolated copy target cannot be a symbolic link");
if (phase === "verified") {
if (!await verifiedIsolatedWorktree(canonicalCwd, roots)) throw forbidden("Isolated copy target is not a verified Git worktree");
// Upgrades and interrupted admissions can already have a registered
// worktree without the newer proof row. Verify the same physical evidence
// during preparation before adopting it; never infer isolation from files.
const linkedWorktree = input.copyKind !== "repository";
const verifiedTarget = linkedWorktree && await verifiedIsolatedWorktree(canonicalCwd, roots);
if (linkedWorktree && (phase === "verified" || verifiedTarget)) {
if (!verifiedTarget) throw forbidden("Isolated copy target is not a verified Git worktree");
for (const scopeId of projectIds) if (!existing.some(entry => entry.kind === "isolated_copy" && entry.scopeId === scopeId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "isolated_copy", scopeId });
}
} else if (await unusedIsolatedCopyTarget(input.targetCwd)) {
} else if (linkedWorktree && await unusedIsolatedCopyTarget(input.targetCwd)) {
// This allowance exists only for this pre-copy check. No proof row is
// written until the harness confirms Git's completed registration.
preparingCopyProjectIds = projectIds;
@@ -708,20 +739,81 @@ export function executionWorkspaceRepositoryService(db: Db) {
async function list(companyId: string, workspaceId: string) {
return db.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.companyId, companyId), eq(executionWorkspaceRepositories.executionWorkspaceId, workspaceId)));
}
type PublicationInput = { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string };
async function publicationBinding(tx: DbTransaction, input: PublicationInput) {
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId)));
if (!issue || !workspace || issue.executionWorkspaceId !== workspace.id || !workspace.cwd
|| await canonicalRepositoryRoot(workspace.cwd) !== await canonicalRepositoryRoot(input.cwd)) {
throw forbidden("Task repository publication binding is unavailable");
}
await requiresAnonymousAcquisition(tx, { issue, workspace, agentId: input.agentId, runId: input.runId });
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, input.companyId)));
const { actor } = await retainedRootReader(tx, input.companyId, {
scopeId: input.runId, issueId: input.issueId, agentId: input.agentId, responsibleUserId: run?.responsibleUserId ?? null,
});
return { workspace, actor };
}
/** Configured-source publication shares this root, but does not acquire a repository. */
async function guardForPublication(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string }) {
async function guardForPublication(input: PublicationInput) {
await db.transaction(async tx => {
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId)));
if (!issue || !workspace || issue.executionWorkspaceId !== workspace.id || !workspace.cwd
|| await canonicalRepositoryRoot(workspace.cwd) !== await canonicalRepositoryRoot(input.cwd)) {
throw forbidden("Task repository publication binding is unavailable");
}
await requiresAnonymousAcquisition(tx, { issue, workspace, agentId: input.agentId, runId: input.runId });
const { workspace } = await publicationBinding(tx, input);
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: workspace.id, reader: input });
});
}
/** Ordinary sibling clones inherit source privacy on their admitted task root.
* These records never prove worktree isolation or confer source WRITE rights.
* The callback rechecks before publication/reuse and keeps captured owners
* even if their configuration disappears while files are being copied. */
async function prepareConfiguredRepositorySource(input: PublicationInput & { sourceCwd: string }): Promise<() => Promise<void>> {
const roots = new Set<string>(), workspaceIds = new Set<string>(), projectIds = new Set<string>();
const retain = async () => {
let sourceFailure: unknown;
try {
roots.add(await fs.realpath(input.sourceCwd));
for (const cwd of await isolatedWorkspaceSourceRoots(input.sourceCwd, "repository")) roots.add(cwd);
} catch (error) { sourceFailure = error; }
const denied = await db.transaction(async tx => {
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
const { workspace, actor } = await publicationBinding(tx, input);
const canonicalCwd = await canonicalRepositoryRoot(input.cwd);
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
const existing = await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
if (existing.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
let failure = sourceFailure;
for (const cwd of roots) {
try {
const source = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd });
for (const id of source.sourceIds) workspaceIds.add(id);
for (const id of source.sourceProjectIds) projectIds.add(id);
} catch (error) { failure ??= error; }
}
for (const id of workspaceIds) {
try { await assertTaskWorkspaceAccess(tx, actor, input.companyId, id); }
catch (error) { failure ??= error; }
}
for (const id of projectIds) {
try { await assertRootProjectAccess(tx, actor, input.companyId, new Set([id])); }
catch (error) { failure ??= error; }
}
for (const [kind, ids] of [["isolated_source", workspaceIds], ["isolated_project_source", projectIds]] as const) {
for (const scopeId of ids) if (!existing.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind, scopeId });
}
}
try {
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: workspace.id,
actor, issueId: input.issueId });
} catch (error) { failure ??= error; }
return failure;
});
if (denied) throw denied;
};
await retain();
return retain;
}
/** Recovery keeps its admitted identity and folder; only fence future publication. */
async function reserveForRecovery(input: { companyId: string; issueId: string; workspaceId: string; cwd: string; agentId: string; runId: string }) {
const [issue] = await db.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
@@ -882,7 +974,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
if (!acquisitionRestricted) {
await assertCurrentControllerAcquisition(input);
const result = await materializeManagedProjectWorkspace(cwd, {
repoUrl: row.repoUrl,
repoUrl: source.repoUrl,
stagingParent,
repoRef: authorizedPin ?? (row.requestedRef === "HEAD" ? null : row.requestedRef),
resolveGitAuth: source.catalogRepositoryId ? resolveAuth : async () => anonymous,
@@ -914,5 +1006,5 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
return prepared;
}
return { request, list, prepareForAdmission, reserveForRecovery, guardForPublication };
return { request, list, prepareForAdmission, reserveForRecovery, guardForPublication, prepareConfiguredRepositorySource };
}
+143 -52
View File
@@ -3,7 +3,7 @@ import { parseObject } from "../adapters/utils.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { instanceSettingsService } from "./instance-settings.js";
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess } from "./execution-workspace-repositories.js";
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess, assertExecutionWorkspaceRootReadAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, canonicalRepositoryRoot, type IsolatedWorkspaceSourceAccess } from "./execution-workspace-repositories.js";
import { forbidden } from "../errors.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared";
import { authorizationService, executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
@@ -2186,6 +2186,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
listTaskRepositories: executionWorkspaceRepositoryService(db).list,
prepareTaskRepositoriesForAdmission: executionWorkspaceRepositoryService(db).prepareForAdmission,
guardTaskRepositoryRootForPublication: executionWorkspaceRepositoryService(db).guardForPublication,
prepareConfiguredRepositorySource: executionWorkspaceRepositoryService(db).prepareConfiguredRepositorySource,
reserveTaskRepositoryRootForRecovery: executionWorkspaceRepositoryService(db).reserveForRecovery,
resolveSourceWorkspaceStrategy, validateSelection, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, prepareTaskWorkspaceUpdate, assertTaskWorkspaceUpdateAccess,
listOverview: async (
@@ -3246,10 +3247,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
// Reopen one closed isolated execution workspace so an authorized issue can
// use it again. The caller must first authorize the request on the issue.
// The whole operation runs under the per-workspace lifecycle lock: it reads
// the row in the issue scope, rebuilds the worktree, and only then publishes
// the row as "active". A rebuild failure keeps the row closed and returns an
// error, so the caller never dispatches a run against a broken workspace.
// Source privacy is retained before the lifecycle transaction, which rechecks
// the binding and rebuilds under its per-workspace fence. Read-only copy
// checks avoid taking the root fence in reverse order. Final provenance is
// committed after lifecycle release, including failed provisioning.
reopenClosedIsolatedExecutionWorkspaceForIssue: async (input: {
workspaceId: string;
issue: { id: string; companyId: string; projectId: string | null };
@@ -3257,7 +3258,102 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
authorizationActor: AuthorizationActor;
}): Promise<ReopenClosedIsolatedExecutionWorkspaceResult> => {
const { issue, actor } = input;
return db.transaction(async (tx): Promise<ReopenClosedIsolatedExecutionWorkspaceResult> => {
const [boundSnapshot] = await db.select({ id: issues.id }).from(issues).where(and(
eq(issues.id, issue.id), eq(issues.companyId, issue.companyId),
eq(issues.executionWorkspaceId, input.workspaceId),
await issueReadSqlCondition(db, input.authorizationActor),
));
const snapshot = boundSnapshot ? await db.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, issue.companyId),
eq(executionWorkspaces.mode, "isolated_workspace"),
await executionWorkspaceReadSqlCondition(db, input.authorizationActor),
)).then(rows => rows[0] ?? null) : null;
if (!snapshot) return { ok: false, code: "not_reopenable", message: "Execution workspace is not reopenable" };
await assertTaskWorkspaceAccess(db, input.authorizationActor, issue.companyId, snapshot.id);
const [
{ ensurePersistedExecutionWorkspaceAvailable },
{ workspaceOperationService },
{ ensureManagedProjectWorkspace },
] = await Promise.all([
import("./workspace-runtime.js"),
import("./workspace-operations.js"),
// heartbeat.js imports this module, so a static import creates a
// cycle. Load ensureManagedProjectWorkspace dynamically instead.
import("./heartbeat.js"),
]);
const [projectWorkspace, projectPolicy] = await Promise.all([
snapshot.projectWorkspaceId
? db
.select({ cwd: projectWorkspaces.cwd })
.from(projectWorkspaces)
.where(and(
eq(projectWorkspaces.companyId, snapshot.companyId),
eq(projectWorkspaces.id, snapshot.projectWorkspaceId),
))
.then((rows) => rows[0] ?? null)
: null,
db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.companyId, snapshot.companyId), snapshot.projectId ? eq(projects.id, snapshot.projectId) : sql`false`))
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)),
]);
// Resolve the base checkout that the rebuild spawns git in. A
// local-folder project stores its base path in projectWorkspaces.cwd.
// A managed_checkout project stores null there, so resolve its live
// managed checkout instead. Never use snapshot.cwd for a git_worktree
// rebuild: snapshot.cwd is the archived worktree path, which the reaper
// already removed from disk. A spawn in that missing directory fails
// with "spawn git ENOENT" and hides the real cause.
let resolvedBaseCwd = projectWorkspace?.cwd ?? null;
if (isClosedExecutionWorkspaceStatus(snapshot.status) && resolvedBaseCwd == null && snapshot.strategyType === "git_worktree" && snapshot.projectId) {
try {
const managedWorkspace = await ensureManagedProjectWorkspace({
companyId: snapshot.companyId,
projectId: snapshot.projectId,
repoUrl: snapshot.repoUrl,
prepareLocalSource: async sourceCwd => {
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, {
companyId: snapshot.companyId, sourceCwd, actor: input.authorizationActor, copyKind: "repository",
});
return targetCwd => retainIsolatedWorkspaceSourceAccess(db, {
companyId: snapshot.companyId, sourceCwd, targetCwd, actor: input.authorizationActor,
captured, issueId: issue.id, copyKind: "repository",
});
},
resolveGitAuth: createGitRemoteAuthProvider(db, snapshot.companyId, {
issueId: snapshot.sourceIssueId ?? issue.id,
}),
});
resolvedBaseCwd = managedWorkspace.cwd;
} catch {
return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" };
}
}
// This transaction commits deny-only ownership before any copy. Holding
// the lifecycle fence while acquiring the company root fence would invert
// archive's lock order and roll ownership back after failed Git writes.
let capturedSource: IsolatedWorkspaceSourceAccess | null = null;
let authorizedTarget: string | null = null;
if (isClosedExecutionWorkspaceStatus(snapshot.status) && snapshot.strategyType === "git_worktree") {
try {
if (!resolvedBaseCwd || !(snapshot.providerRef ?? snapshot.cwd)) throw new Error("Missing workspace copy identity");
authorizedTarget = await canonicalRepositoryRoot(snapshot.providerRef ?? snapshot.cwd!);
if (snapshot.cwd && await canonicalRepositoryRoot(snapshot.cwd) !== authorizedTarget) {
throw new Error("Workspace checkout and provider roots disagree");
}
capturedSource = await assertIsolatedWorkspaceSourceReadAccess(db, {
companyId: snapshot.companyId, sourceCwd: resolvedBaseCwd, actor: input.authorizationActor,
});
await retainIsolatedWorkspaceSourceAccess(db, {
companyId: snapshot.companyId, sourceCwd: resolvedBaseCwd, targetCwd: authorizedTarget,
actor: input.authorizationActor, captured: capturedSource, issueId: issue.id, phase: "preparing",
});
} catch {
return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" };
}
}
const result = await db.transaction(async (tx): Promise<ReopenClosedIsolatedExecutionWorkspaceResult> => {
await acquireExecutionWorkspaceLifecycleLock(tx, input.workspaceId);
// Organizational project assignment does not identify the source of files.
// Reopen only the task's persisted binding, with current source access.
@@ -3284,6 +3380,9 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
}
await assertTaskWorkspaceAccess(tx, input.authorizationActor, issue.companyId, row.id);
if (!isClosedExecutionWorkspaceStatus(row.status)) {
if (row.cwd) await assertExecutionWorkspaceRootReadAccess(tx, {
companyId: row.companyId, cwd: row.cwd, actor: input.authorizationActor,
});
// A concurrent reopen already restored the row. Report success without a
// second rebuild so the caller continues normally. The other request
// owns the reopen-pending flag, so return its generation and let the
@@ -3296,53 +3395,29 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
};
}
const [
{ ensurePersistedExecutionWorkspaceAvailable },
{ workspaceOperationService },
{ ensureManagedProjectWorkspace },
] = await Promise.all([
import("./workspace-runtime.js"),
import("./workspace-operations.js"),
// heartbeat.js imports this module, so a static import creates a
// cycle. Load ensureManagedProjectWorkspace dynamically instead.
import("./heartbeat.js"),
]);
const [projectWorkspace, projectPolicy] = await Promise.all([
row.projectWorkspaceId
? db
.select({ cwd: projectWorkspaces.cwd })
.from(projectWorkspaces)
.where(and(
eq(projectWorkspaces.companyId, row.companyId),
eq(projectWorkspaces.id, row.projectWorkspaceId),
))
.then((rows) => rows[0] ?? null)
: null,
db
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
.from(projects)
.where(and(eq(projects.companyId, row.companyId), row.projectId ? eq(projects.id, row.projectId) : sql`false`))
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy)),
]);
// Resolve the base checkout that the rebuild spawns git in. A
// local-folder project stores its base path in projectWorkspaces.cwd.
// A managed_checkout project stores null there, so resolve its live
// managed checkout instead. Never use row.cwd for a git_worktree
// rebuild: row.cwd is the archived worktree path, which the reaper
// already removed from disk. A spawn in that missing directory fails
// with "spawn git ENOENT" and hides the real cause.
let resolvedBaseCwd = projectWorkspace?.cwd ?? null;
if (resolvedBaseCwd == null && row.strategyType === "git_worktree" && row.projectId) {
const managedWorkspace = await ensureManagedProjectWorkspace({
companyId: row.companyId,
projectId: row.projectId,
repoUrl: row.repoUrl,
resolveGitAuth: createGitRemoteAuthProvider(db, row.companyId, {
issueId: row.sourceIssueId ?? issue.id,
}),
});
resolvedBaseCwd = managedWorkspace.cwd;
if (capturedSource && !["projectId", "projectWorkspaceId", "cwd", "providerRef", "repoUrl", "baseRef", "branchName", "strategyType", "mode", "metadata"]
.every(key => isDeepStrictEqual(row[key as keyof typeof row], snapshot[key as keyof typeof snapshot]))) {
return { ok: false, code: "not_reopenable", message: "Execution workspace source changed during reopen" };
}
if (capturedSource && snapshot.projectWorkspaceId) {
const [currentSource] = await tx.select({ cwd: projectWorkspaces.cwd }).from(projectWorkspaces)
.where(and(eq(projectWorkspaces.id, snapshot.projectWorkspaceId), eq(projectWorkspaces.companyId, snapshot.companyId)));
if (!isDeepStrictEqual(currentSource ?? null, projectWorkspace)) {
return { ok: false, code: "not_reopenable", message: "Execution workspace source changed during reopen" };
}
}
const verifyCopyIdentity = capturedSource && authorizedTarget ? async (sourceCwd: string, targetCwd: string) => {
if (!capturedSource!.roots.includes(await fs.realpath(sourceCwd))
|| await canonicalRepositoryRoot(targetCwd) !== authorizedTarget) {
throw forbidden("Execution workspace copy identity changed during reopen");
}
// Read-only checks under the lifecycle fence cannot acquire the
// root mutation fence. The target already retains the captured owners.
for (const cwd of [...capturedSource!.roots, authorizedTarget!]) await assertExecutionWorkspaceRootReadAccess(tx, {
companyId: row.companyId, cwd, actor: input.authorizationActor,
});
} : undefined;
const config = readExecutionWorkspaceConfig(row.metadata as Record<string, unknown> | null);
const nextGeneration = readExecutionWorkspaceLifecycleGeneration(
row.metadata as Record<string, unknown> | null,
@@ -3359,6 +3434,8 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
try {
const realized = await ensurePersistedExecutionWorkspaceAvailable({
db: tx as unknown as Db,
beforeSourceCopy: verifyCopyIdentity,
afterSourceCopy: verifyCopyIdentity,
base: {
baseCwd: resolvedBaseCwd ?? row.cwd ?? "",
source: "task_session",
@@ -3504,7 +3581,21 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
},
});
return { ok: true, reopened: true, workspace: toExecutionWorkspace(activeRow), generation: nextGeneration };
}).catch((error): ReopenClosedIsolatedExecutionWorkspaceResult => {
logger.warn({ error, executionWorkspaceId: snapshot.id }, "Execution workspace reopen failed");
return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" };
});
if (capturedSource && authorizedTarget && resolvedBaseCwd) {
try {
await retainIsolatedWorkspaceSourceAccess(db, {
companyId: snapshot.companyId, sourceCwd: resolvedBaseCwd, targetCwd: authorizedTarget,
actor: input.authorizationActor, captured: capturedSource, issueId: issue.id, phase: "verified",
});
} catch {
return { ok: false, code: "rebuild_failed", message: "Failed to rebuild the execution workspace" };
}
}
return result;
},
// Clear the reopen-pending flag after a caller failed to consume a reopened
@@ -1,22 +1,23 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, access } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, access, rename } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { captureDirectorySnapshot, directorySnapshotSha256, serializeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, environmentLeases, heartbeatRuns, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db";
import { agents, companies, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, environmentLeases, executionWorkspaces, executionWorkspaceRepositoryAccess, heartbeatRuns, issues, issueAccessGrants, projects, projectWorkspaces, type Db } from "@paperclipai/db";
import { eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { environmentRunOrchestrator } from "../environment-run-orchestrator.js";
import { environmentService } from "../environments.js";
import { assertExecutionWorkspaceRootReadAccess } from "../execution-workspace-repositories.js";
import { executionWorkspaceService } from "../execution-workspaces.js";
import { instanceSettingsService } from "../instance-settings.js";
import { workspaceOperationService } from "../workspace-operations.js";
import { nativeWorkspaceSyncInternals, prepareNativeWorkspaceSync } from "../native-runtime/native-workspace-sync.js";
import { createHeartbeatWorkspaceResolver, resolveExecutionWorkspaceReuseRequestForIssue } from "./workspaces.js";
import { createHeartbeatWorkspaceResolver, prepareProjectRepositoryWorkspaces, resolveExecutionWorkspaceReuseRequestForIssue } from "./workspaces.js";
import { prepareHeartbeatWorkspace, type HeartbeatWorkspacePreparationInput } from "./workspace-preparation.js";
const execFile = promisify(execFileCallback);
@@ -146,6 +147,59 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
expect(await leases(input.run.id)).toEqual([]);
});
it("retains sibling privacy before copying when its source disappears before publication", async () => {
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
const { input, source, issue } = await fixture();
const sibling = source + "-private-sibling";
await execFile("git", ["clone", "--no-hardlinks", source, sibling]);
await writeFile(join(sibling, "private.txt"), "PRIVATE_SIBLING_STAGING_BYTES");
const [privateIssue] = await db.insert(issues).values({ companyId: issue.companyId, title: "Private sibling owner",
visibility: "private", responsibleUserId: "private-owner" }).returning();
const [privateWorkspace] = await db.insert(executionWorkspaces).values({ companyId: issue.companyId,
name: "Private sibling", projectId: null, sourceIssueId: privateIssue.id, cwd: sibling,
providerType: "local_fs", strategyType: "project_primary", mode: "shared_workspace", status: "active" }).returning();
await db.insert(issueAccessGrants).values({ issueId: privateIssue.id, subjectType: "agent", subjectId: input.agent.id, source: "explicit" });
const [configured] = await db.insert(projectWorkspaces).values({ companyId: issue.companyId, projectId: issue.projectId!,
name: "Private sibling", cwd: sibling, repoUrl: pathToFileURL(sibling).href }).returning();
const namespace = join(source, ".paperclip-repositories");
const originalPrepare = input.services.executionWorkspacesSvc.prepareConfiguredRepositorySource;
const prepare = vi.spyOn(input.services.executionWorkspacesSvc, "prepareConfiguredRepositorySource").mockImplementation(async args => {
const recheck = await originalPrepare(args);
return async () => {
const candidates = await readdir(namespace);
expect(candidates).toHaveLength(1);
expect(candidates[0]).toContain(".clone-");
expect(await readFile(join(namespace, candidates[0]!, "private.txt"), "utf8")).toBe("PRIVATE_SIBLING_STAGING_BYTES");
const retained = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, issue.companyId));
expect(retained).toContainEqual(expect.objectContaining({ kind: "isolated_source", scopeId: privateWorkspace.id }));
await rename(sibling, sibling + "-moved");
await db.update(projectWorkspaces).set({ cwd: sibling + "-moved" }).where(eq(projectWorkspaces.id, configured.id));
await recheck();
};
});
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow(/ENOENT|unavailable|inaccessible|no longer/i);
expect(prepare).toHaveBeenCalledOnce();
expect(await readdir(namespace)).toEqual([]);
expect(await leases(input.run.id)).toEqual([]);
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, privateIssue.id));
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: issue.companyId, cwd: source,
actor: { type: "agent", source: "agent_jwt", companyId: issue.companyId, agentId: input.agent.id } })).rejects.toThrow();
} finally {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", previousPrivacyMode);
}
});
it.each(["file://untrusted-host/private/repository", "file:///private%2Frepository", "file:///private/repository?other", "file:///private/repository#other"])("rejects malformed local repository URL before source access: %s", async repoUrl => {
const { source, issue } = await fixture();
const prepareLocalSource = vi.fn();
await expect(prepareProjectRepositoryWorkspaces({ cwd: source, anchorRepoUrl: null,
workspaces: [{ id: issue.projectWorkspaceId!, repoUrl, repoRef: null, cwd: null }], prepareLocalSource })).rejects.toThrow();
expect(prepareLocalSource).not.toHaveBeenCalled();
expect(await readdir(join(source, ".paperclip-repositories"))).toEqual([]);
});
it("persists and reuses task-owned files without adopting the organizational project as their source", async () => {
const { input, issue } = await fixture();
await db.update(issues).set({ projectWorkspaceId: null,
@@ -130,7 +130,7 @@ export interface HeartbeatWorkspacePreparationInput {
};
services: {
envOrchestrator: Pick<ReturnType<typeof environmentRunOrchestrator>, "resolveEnvironment" | "acquireForRun" | "realizeForRun">;
executionWorkspacesSvc: Pick<ReturnType<typeof executionWorkspaceService>, "create" | "update" | "bindTaskWorkspace" | "prepareTaskRepositoriesForAdmission" | "guardTaskRepositoryRootForPublication" | "reserveTaskRepositoryRootForRecovery">;
executionWorkspacesSvc: Pick<ReturnType<typeof executionWorkspaceService>, "create" | "update" | "bindTaskWorkspace" | "prepareTaskRepositoriesForAdmission" | "guardTaskRepositoryRootForPublication" | "prepareConfiguredRepositorySource" | "reserveTaskRepositoryRootForRecovery">;
workspaceOperationsSvc: Pick<ReturnType<typeof workspaceOperationService>, "createRecorder">;
resolveWorkspaceForRun: WorkspaceResolver["resolveWorkspaceForRun"];
resolveReusedGitWorkspaceAnchor: WorkspaceResolver["resolveReusedGitWorkspaceAnchor"];
@@ -909,7 +909,7 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
}
if (executionWorkspace.projectId
&& (resolvedWorkspace.source === "project_primary" || Boolean(reusableExistingExecutionWorkspace?.projectWorkspaceId))
&& !resolvedWorkspace.baseCwdFallback && !persistedNativeExecutionInput) {
&& !resolvedWorkspace.baseCwdFallback && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
const repositoryRows = await db.select().from(projectWorkspaces).where(and(
eq(projectWorkspaces.companyId, agent.companyId),
eq(projectWorkspaces.projectId, executionWorkspace.projectId),
@@ -919,6 +919,11 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
anchorRepoUrl: executionWorkspace.repoUrl,
workspaces: repositoryRows,
resolveGitAuth: workspaceGitAuthProvider,
prepareLocalSource: persistedExecutionWorkspace && issueId
? (sourceCwd) => executionWorkspacesSvc.prepareConfiguredRepositorySource({
companyId: agent.companyId, issueId, workspaceId: persistedExecutionWorkspace!.id,
cwd: executionWorkspace.cwd, agentId: agent.id, runId: run.id, sourceCwd,
}) : undefined,
beforePublish: persistedExecutionWorkspace && issueId && !nativeRecoveryExecutionWorkspaceId
? () => executionWorkspacesSvc.guardTaskRepositoryRootForPublication({
companyId: agent.companyId, issueId, workspaceId: persistedExecutionWorkspace!.id,
+92 -6
View File
@@ -1,3 +1,5 @@
import { assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess } from "../execution-workspace-repositories.js";
import { forbidden } from "../../errors.js";
import { readGitConnectionFailure } from "../git-connection-failure.js";
import { materializeManagedProjectWorkspace, ensureManagedRepositoriesIgnored } from "../managed-repository-checkout.js";
export { materializeManagedProjectWorkspace } from "../managed-repository-checkout.js";
@@ -11,6 +13,7 @@ import {
} from "@paperclipai/adapter-utils/git-workspace-sync";
import fs from "node:fs/promises";
import path from "node:path";
import { localRepositoryPath } from "../local-repository-path.js";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import {
@@ -439,6 +442,8 @@ function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
}
}
type PrepareManagedLocalSource = (sourceCwd: string, targetCwd: string) => Promise<(targetCwd: string) => Promise<void>>;
/**
* In-flight managed-checkout materializations keyed by target cwd. Two issues on the same
* project can wake within seconds of each other; without this, both runs raced the same
@@ -454,6 +459,7 @@ export async function ensureManagedProjectWorkspace(input: {
companyId: string;
projectId: string;
repoUrl: string | null;
prepareLocalSource?: PrepareManagedLocalSource;
/** Optional git credential source for cloning private repos; null/absent preserves ambient behavior. */
resolveGitAuth?: GitRemoteAuthProvider | null;
}): Promise<{ cwd: string; warning: string | null }> {
@@ -476,11 +482,20 @@ export async function ensureManagedProjectWorkspace(input: {
// The winner may have cloned a different repository with the same basename.
return ensureManagedProjectWorkspace(input);
}
const attempt = materializeManagedProjectWorkspace(cwd, input).finally(() => {
const localSource = input.repoUrl ? localRepositoryPath(input.repoUrl) : null;
const guardLocalSource = localSource ? await input.prepareLocalSource?.(localSource, cwd) : undefined;
await guardLocalSource?.(cwd);
const attempt = materializeManagedProjectWorkspace(cwd, {
...input,
beforeCopy: guardLocalSource,
beforePublish: guardLocalSource ? () => guardLocalSource(cwd) : undefined,
}).finally(() => {
managedCheckoutMaterializations.delete(cwd);
});
managedCheckoutMaterializations.set(cwd, attempt);
const result = await attempt;
// Covers the materializer's existing checkout and competing-publisher paths.
await guardLocalSource?.(cwd);
if (input.repoUrl) {
// A different server process can publish a same-name checkout between the
// initial origin check and the atomic rename. Never adopt its other repo.
@@ -501,6 +516,9 @@ export async function prepareProjectRepositoryWorkspaces(input: {
workspaces: Array<Pick<typeof projectWorkspaces.$inferSelect, "id" | "repoUrl" | "repoRef"> & { cwd?: string | null }>;
resolveGitAuth?: GitRemoteAuthProvider | null;
beforePublish?: () => Promise<void>;
/** Authorize and retain the configured local source before cloning or reuse.
* The returned guard rechecks that same source envelope before publication. */
prepareLocalSource?: (sourceCwd: string) => Promise<() => Promise<void>>;
}): Promise<Array<{ workspaceId: string; cwd: string; repoUrl: string; repoRef: string | null }>> {
const identity = (url: string) => url.trim().replace(/\.git\/?$/, "").replace(/\/$/, "");
const seen = new Set(input.anchorRepoUrl ? [identity(input.anchorRepoUrl)] : []);
@@ -524,11 +542,27 @@ export async function prepareProjectRepositoryWorkspaces(input: {
const cwd = path.join(root, key);
const existing = await fs.lstat(cwd).catch(() => null);
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) throw new Error("Invalid project repository checkout path");
const localSource = workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL
&& await fs.stat(workspace.cwd).then((entry) => entry.isDirectory()).catch(() => false)
? workspace.cwd : null;
const result = await materializeManagedProjectWorkspace(cwd, { repoUrl, repoRef: workspace.repoRef, localSource, resolveGitAuth: input.resolveGitAuth, beforePublish: input.beforePublish });
const configuredLocalSource = workspace.cwd && workspace.cwd !== REPO_ONLY_CWD_SENTINEL ? workspace.cwd : null;
// A URL-only clone can still read this host. Keep its committed-only clone
// behavior, but authorize the physical file source before any Git read.
const urlLocalSource = localRepositoryPath(repoUrl);
// Run before the materializer's existing-checkout shortcut too. A sibling
// added after the first admission still contributes private source bytes.
const sourceToAuthorize = configuredLocalSource ?? urlLocalSource;
const recheckLocalSource = sourceToAuthorize
? await input.prepareLocalSource?.(sourceToAuthorize) : undefined;
const localSource = configuredLocalSource
&& await fs.stat(configuredLocalSource).then((entry) => entry.isDirectory()).catch(() => false)
? configuredLocalSource : null;
const beforePublish = async () => {
await recheckLocalSource?.();
await input.beforePublish?.();
};
const result = await materializeManagedProjectWorkspace(cwd, { repoUrl, repoRef: workspace.repoRef, localSource, resolveGitAuth: input.resolveGitAuth, beforePublish });
if (result.warning) throw new Error(result.warning);
// Existing clones and a competing publisher can return without invoking
// beforePublish. Reauthorize before exposing either result to the run.
await beforePublish();
results.push({ workspaceId: workspace.id, cwd, repoUrl, repoRef: workspace.repoRef });
}
// Retain detached checkout work outside the synchronized repository set.
@@ -554,6 +588,7 @@ async function resolveConfiguredOrManagedProjectCwd(input: {
cwd: string | null;
repoUrl: string | null;
resolveGitAuth?: GitRemoteAuthProvider | null;
prepareLocalSource?: PrepareManagedLocalSource;
}): Promise<{ cwd: string; warning: string | null }> {
const configuredCwd = readNonEmptyString(input.cwd);
if (configuredCwd && configuredCwd !== REPO_ONLY_CWD_SENTINEL) {
@@ -564,6 +599,7 @@ async function resolveConfiguredOrManagedProjectCwd(input: {
projectId: input.projectId,
repoUrl: readNonEmptyString(input.repoUrl),
resolveGitAuth: input.resolveGitAuth ?? null,
prepareLocalSource: input.prepareLocalSource,
});
}
@@ -586,6 +622,7 @@ export interface ResolveAdditionalProjectWorkspaceDeps {
function defaultAdditionalProjectWorkspaceDeps(
db: Db,
resolveGitAuth?: GitRemoteAuthProvider,
prepareLocalSource?: PrepareManagedLocalSource,
): ResolveAdditionalProjectWorkspaceDeps {
return {
loadProjectWorkspaceRows: (companyId, projectId) =>
@@ -602,6 +639,7 @@ function defaultAdditionalProjectWorkspaceDeps(
resolveConfiguredOrManagedProjectCwd: (input) =>
resolveConfiguredOrManagedProjectCwd({
...input,
prepareLocalSource: input.prepareLocalSource ?? prepareLocalSource,
resolveGitAuth:
input.resolveGitAuth ??
resolveGitAuth ??
@@ -610,6 +648,7 @@ function defaultAdditionalProjectWorkspaceDeps(
ensureManagedProjectWorkspace: (input) =>
ensureManagedProjectWorkspace({
...input,
prepareLocalSource: input.prepareLocalSource ?? prepareLocalSource,
resolveGitAuth:
input.resolveGitAuth ??
resolveGitAuth ??
@@ -3239,6 +3278,50 @@ function readNonEmptyString(value: unknown): string | null {
/** Database-bound workspace resolution for a heartbeat service instance. */
export function createHeartbeatWorkspaceResolver(db: Db) {
function managedLocalSourceGuard(
agent: typeof agents.$inferSelect,
issueId: string | null,
runId: string | null,
): PrepareManagedLocalSource {
return async (sourceCwd) => {
const [run] = runId
? await db.select().from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, runId),
eq(heartbeatRuns.companyId, agent.companyId),
eq(heartbeatRuns.agentId, agent.id),
))
: [];
const runIssueId = run?.nativeIssueId ?? run?.contextSnapshot?.issueId
?? run?.contextSnapshot?.taskId ?? run?.issueId ?? null;
if (!run || runIssueId !== issueId) {
throw forbidden("Local repository source requires an authoritative run identity");
}
const actor = {
type: "agent" as const,
source: "agent_jwt" as const,
companyId: agent.companyId,
agentId: agent.id,
runId: run.id,
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId,
};
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, {
companyId: agent.companyId,
sourceCwd,
actor,
copyKind: "repository",
});
return targetCwd => retainIsolatedWorkspaceSourceAccess(db, {
companyId: agent.companyId,
sourceCwd,
targetCwd,
actor,
captured,
issueId: issueId ?? undefined,
copyKind: "repository",
});
};
}
async function resolveReusedGitWorkspaceAnchor(input: {
agent: typeof agents.$inferSelect;
workspace: ExecutionWorkspace;
@@ -3288,6 +3371,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
companyId: agent.companyId,
projectId: workspace.projectId!,
repoUrl: workspace.repoUrl,
prepareLocalSource: managedLocalSourceGuard(agent, input.issueId, input.runId),
resolveGitAuth: createGitRemoteAuthProvider(db, agent.companyId, {
issueId: input.issueId, heartbeatRunId: input.runId, agentId: agent.id,
responsibleUserId: input.responsibleUserId,
@@ -3417,6 +3501,7 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
resolveGitAuth,
prepareLocalSource: managedLocalSourceGuard(agent, issueId, readNonEmptyString(context.executionIdentityRunId)),
});
projectCwd = resolvedCwd.cwd;
managedWorkspaceWarning = resolvedCwd.warning;
@@ -3625,7 +3710,8 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
resolveProjectWorkspace: (project) =>
resolveAdditionalProjectWorkspace(
{ companyId: agent.companyId, project },
defaultAdditionalProjectWorkspaceDeps(db, resolveGitAuth),
defaultAdditionalProjectWorkspaceDeps(db, resolveGitAuth,
managedLocalSourceGuard(agent, issueId, readNonEmptyString(context.executionIdentityRunId))),
),
});
@@ -0,0 +1,14 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
/** Resolve only Git transports that read the local host filesystem. */
export function localRepositoryPath(repoUrl: string): string | null {
if (/^file:/i.test(repoUrl.trim())) {
const localUrl = new URL(repoUrl);
if (repoUrl !== repoUrl.trim() || localUrl.search || localUrl.hash) {
throw new Error("Local repository URLs must not contain surrounding whitespace, queries, or fragments");
}
return fileURLToPath(localUrl);
}
return path.isAbsolute(repoUrl) ? repoUrl : null;
}
@@ -20,6 +20,8 @@ export async function materializeManagedProjectWorkspace(
repoRef?: string | null;
localSource?: string | null;
resolveGitAuth?: GitRemoteAuthProvider | null;
/** Protect the fresh staging directory before Git reads any local source bytes. */
beforeCopy?: (cloneCwd: string) => Promise<void>;
/** Internal preparation receipt is committed inside the clone before atomic publication. */
beforePublish?: (cloneCwd: string) => Promise<void>;
/** Existing, validated private staging parent outside published repositories. */
@@ -67,6 +69,7 @@ export async function materializeManagedProjectWorkspace(
? path.join(input.stagingParent, `${path.basename(cwd)}.clone-`)
: `${cwd}.clone-`);
try {
await input.beforeCopy?.(cloneTmpDir);
try {
await execFile(
"git",