Fence shared workspace readers and preserve projectless chat admission

Retain root source and reader provenance, recheck publication access, keep plain agent transfers independent of repository discovery, and resolve organizational projects without filesystem sources.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 08:12:05 -05:00
1 parent c205ab39f0
commit e1863d4fe7
24 files changed
+1088 -58320

No files matched your search

@@ -91,7 +91,7 @@ describe("stable live agent directory observation", () => {
const baseline = await captureDirectorySnapshot(root);
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "probe-transfer", adapterKey: "agent-files",
workspaceLocalDir: root, workspaceRemoteDir: remote, syncWorkspace: true, workspaceBaseline: baseline,
workspaceGitSnapshot: null, workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
expect(fs.readdirSync(`${remote}/.paperclip-runtime`)).toEqual(["agent-files"]);
expect(fs.readdirSync(`${remote}/.paperclip-runtime/agent-files`)).toEqual([]);
execFileSync(process.execPath, ["-e", agentDirectoryTransferCleanupProgram, remote], { env: {}, timeout: 10_000 });
@@ -99,6 +99,49 @@ describe("stable live agent directory observation", () => {
expect(probeAgentDirectory(remote).digest).toBe(agentDirectoryBaselineDigest(baseline));
} finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remote, { recursive: true, force: true }); }
});
it.each([false, true])("round-trips the raw agent-directory repository namespace without discovery (recovering=%s)", async recovering => {
const remoteCwd = `${root}-remote`;
const agentHome = join(remoteCwd, ".paperclip-runtime", "agent-files", "agent", "original-run");
const scratch = join(remoteCwd, ".paperclip-runtime", "paperclip-runner", "agent-file-transfers", "agent", "original-run");
const rawFiles = {
".paperclip-repositories/notes.txt": "Ordinary agent memory\n",
".paperclip-repositories/archive/.git/config": "Raw nested configuration\n",
".paperclip-repositories/archive/.git/hooks/post-commit": "Raw nested hook\n",
".git/HEAD": "Raw root Git metadata\n",
"node_modules/memory.bin": "Ordinary cache directory bytes\n",
};
for (const [relative, contents] of Object.entries(rawFiles)) {
fs.mkdirSync(join(root, relative, ".."), { recursive: true });
fs.writeFileSync(join(root, relative), contents);
}
fs.mkdirSync(agentHome, { recursive: true });
if (recovering) fs.cpSync(root, agentHome, { recursive: true });
const target = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd,
runner: { execute: async (input: Parameters<import("@paperclipai/adapter-utils/command-managed-runtime").CommandManagedRuntimeRunner["execute"]>[0]) => ({
stdout: execFileSync(input.command, input.args ?? [], { cwd: input.cwd, input: input.stdin, encoding: "utf8",
env: { PATH: process.env.PATH, ...input.env }, timeout: input.timeoutMs, maxBuffer: 32 * 1024 * 1024 }),
stderr: "", exitCode: 0, signal: null, timedOut: false, pid: null, startedAt: new Date().toISOString(),
}) } };
let runtime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | undefined;
try {
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "plain-agent-directory", adapterKey: "agent-files",
workspaceLocalDir: root, workspaceRemoteDir: agentHome, runtimeRootDir: scratch, syncWorkspace: true,
workspaceInboundMode: recovering ? "adopt_remote" : undefined, workspaceBaseline: await captureDirectorySnapshot(root),
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all",
workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
for (const [relative, contents] of Object.entries(rawFiles)) expect(fs.readFileSync(join(agentHome, relative), "utf8")).toBe(contents);
expect(probeAgentDirectory(agentHome).digest).toBe(probeAgentDirectory(root).digest);
fs.writeFileSync(join(agentHome, ".paperclip-repositories/notes.txt"), "Updated memory\n");
fs.writeFileSync(join(agentHome, ".paperclip-repositories/archive/.git/hooks/post-commit"), "Updated raw hook\n");
fs.unlinkSync(join(agentHome, "notes/memory.bin"));
await runtime.restoreWorkspace();
expect(fs.readFileSync(join(root, ".paperclip-repositories/notes.txt"), "utf8")).toBe("Updated memory\n");
expect(fs.readFileSync(join(root, ".paperclip-repositories/archive/.git/hooks/post-commit"), "utf8")).toBe("Updated raw hook\n");
expect(fs.existsSync(join(root, "notes/memory.bin"))).toBe(false);
expect(fs.readFileSync(join(root, ".git/HEAD"), "utf8")).toBe(rawFiles[".git/HEAD"]);
expect(probeAgentDirectory(agentHome).digest).toBe(probeAgentDirectory(root).digest);
} finally { await runtime?.cleanupWorkspaceSnapshot?.(); fs.rmSync(remoteCwd, { recursive: true, force: true }); }
});
it("keeps restart fallback transfer scratch outside the native memory directory", async () => {
const remoteCwd = `${root}-remote`;
const agentHome = join(remoteCwd, ".paperclip-runtime", "agent-files", "agent", "original-run");
@@ -119,7 +162,7 @@ describe("stable live agent directory observation", () => {
runtime = await prepareAdapterExecutionTargetRuntime({ target, runId: "restart-transfer", adapterKey: "agent-files",
workspaceLocalDir: root, workspaceRemoteDir: agentHome, runtimeRootDir: scratch,
syncWorkspace: true, workspaceInboundMode: "adopt_remote", workspaceBaseline: baseline,
workspaceGitSnapshot: null, workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all", workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
const memory = "0123456789abcdef0123456789abcdef\n";
fs.writeFileSync(join(agentHome, "notes", "memory.txt"), memory);
await runtime.restoreWorkspace();
@@ -3338,11 +3338,18 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
});
it("dispatches local native external chat inside the server-selected task root", async () => {
it.each(["projectless", "organization_only", "empty_source"] as const)("dispatches local native external chat inside the server-selected task root: %s", async placement => {
await withTempPaperclipHome(async () => {
const { companyId, agentId, issueId, runId } =
await seedQueuedIssueRunFixture();
await fs.mkdir(resolvePaperclipInstanceRoot(), { recursive: true });
const projectId = placement === "projectless" ? null : randomUUID();
if (projectId) {
await db.insert(projects).values({ id: projectId, companyId, name: "Chat channel project default" });
if (placement === "empty_source") {
await db.insert(projectWorkspaces).values({ companyId, projectId, name: "Unconfigured source", cwd: " ", repoUrl: null });
}
}
await db
.update(agents)
.set({
@@ -3352,7 +3359,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
.where(eq(agents.id, agentId));
await db
.update(issues)
.set({ originKind: "chat_channel" })
.set({ originKind: "chat_channel", projectId })
.where(eq(issues.id, issueId));
await db
.update(heartbeatRuns)
@@ -3390,6 +3397,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
expect(input.workspace.cwd).not.toBe(
resolveDefaultAgentWorkspaceDir(agentId),
);
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, task.executionWorkspaceId!));
expect(task.projectId).toBe(projectId);
expect(workspace).toMatchObject({ sourceIssueId: issueId, projectId: null, projectWorkspaceId: null, cwd: input.workspace.cwd });
expect(mockAdapterExecute).not.toHaveBeenCalled();
});
});
@@ -64,14 +64,18 @@ suite("task project repository provisioning", () => {
});
});
it("isolates repository-free low-trust tasks while preserving reassignment and authorized referenced projects", async () => {
it.each(["agent", "organization"] as const)("isolates repository-free low-trust tasks while preserving reassignment and authorized referenced projects (%s policy)", async policyOwner => {
const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), environmentId = randomUUID();
const referencedProjectId = randomUUID(), deniedProjectId = randomUUID();
vi.stubEnv("PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC", "true");
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
await db.insert(companies).values({ id: companyId, name: "Email company", issuePrefix: `E${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: "Onboarding" });
const authorizationPolicy = {
trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId, projectIds: [projectId, referencedProjectId] },
};
await db.insert(projects).values({ id: projectId, companyId, name: "Onboarding",
executionWorkspacePolicy: policyOwner === "organization" ? { authorizationPolicy } : null });
for (const id of [referencedProjectId, deniedProjectId]) {
const source = path.join(root, companyId, id);
await mkdir(source, { recursive: true });
@@ -79,13 +83,11 @@ suite("task project repository provisioning", () => {
await db.insert(projects).values({ id, companyId, name: id });
await db.insert(projectWorkspaces).values({ id: randomUUID(), companyId, projectId: id, name: "Reference", sourceType: "local_path", cwd: source, isPrimary: true });
}
await db.insert(environments).values({ id: environmentId, name: "Email sandbox", driver: "sandbox", status: "active", config: { provider: "fake", image: "fake:test" } });
await db.insert(environments).values({ id: environmentId, name: `Email sandbox ${policyOwner}`, driver: "sandbox", status: "active", config: { provider: "fake", image: "fake:test" } });
await db.insert(agents).values({
id: agentId, companyId, name: "Email agent", role: "engineer", status: "idle", adapterType: "codex_local",
defaultEnvironmentId: environmentId, adapterConfig: {}, runtimeConfig: {},
permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId, projectIds: [projectId, referencedProjectId] },
} },
permissions: policyOwner === "agent" ? { trustPreset: "low_trust_review", authorizationPolicy } : {},
});
// Exercise the real lease and workspace lifecycle with the built-in fake
// provider, executing its setup commands in a disposable filesystem root.
@@ -120,6 +122,11 @@ suite("task project repository provisioning", () => {
expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(1);
const call = execute.mock.calls.find(([input]) => input.runId === run!.id)![0];
expect(call.context.paperclipEnvironment.driver).toBe("sandbox");
expect((await sandboxHeartbeat.getRun(run!.id))?.contextSnapshot).toMatchObject({
executionPolicy: { trustPreset: "low_trust_review", authorizationPolicy: {
trustBoundary: { projectIds: [projectId, referencedProjectId].sort() },
} },
});
expect(call.context.paperclipWorkspaces.map((workspace: { projectId: string }) => workspace.projectId)).toEqual([referencedProjectId]);
directories.push(workspace.cwd!);
await writeFile(path.join(workspace.cwd!, "email.txt"), `private email ${index}`);
@@ -248,14 +255,14 @@ suite("task project repository provisioning", () => {
await expect(readFile(path.join(agentDefaultCwd, "output.txt"), "utf8")).rejects.toThrow();
}, 40_000);
it("keeps a task directory source projectless across chat admissions with an organizational project", async () => {
it.each([false, true])("keeps a task directory source projectless across chat admissions with an organizational project (explicit=%s)", async explicit => {
const companyId = randomUUID(), projectId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Chat files", issuePrefix: `C${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: "Organization only" });
await db.insert(agents).values({ id: agentId, companyId, name: "Chat agent", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} });
await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Keep chat files", originKind: "chat_channel", status: "todo", assigneeAgentId: agentId,
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "task_directory" } } });
workspaceSelection: explicit ? { version: 1, source: "explicit", selection: { kind: "task_directory" } } : null });
let bindingId: string | undefined;
for (let admission = 0; admission < 2; admission++) {
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, issueId));
@@ -274,7 +281,7 @@ suite("task project repository provisioning", () => {
}
}, 40_000);
it.each(["task_directory", "configured_source"] as const)("keeps organizational environment separate from %s files", async (kind) => {
it.each(["task_directory", "configured_source", "default"] as const)("keeps organizational environment separate from %s files", async (kind) => {
const companyId = randomUUID(), projectId = randomUUID(), sourceProjectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const cwd = path.join(root, companyId, "source");
await mkdir(cwd, { recursive: true });
@@ -288,7 +295,7 @@ suite("task project repository provisioning", () => {
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} });
await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Use task environment", status: "todo", assigneeAgentId: agentId, executionWorkspaceSettings: { mode: "shared_workspace" },
projectWorkspaceId: kind === "configured_source" ? sourceId : null,
workspaceSelection: { version: 1, source: "explicit", selection: kind === "configured_source"
workspaceSelection: kind === "default" ? null : { version: 1, source: "explicit", selection: kind === "configured_source"
? { kind, projectWorkspaceId: sourceId, mode: "shared" } : { kind } },
});
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } });
@@ -303,6 +310,7 @@ suite("task project repository provisioning", () => {
it.each([
{ policy: "organization", selection: "task_directory" },
{ policy: "organization", selection: "default" },
{ policy: "organization", selection: "configured_source" },
{ policy: "source", selection: "configured_source" },
] as const)("retains $policy low-trust restrictions with $selection", async ({ policy, selection }) => {
@@ -320,7 +328,7 @@ suite("task project repository provisioning", () => {
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} });
await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Do not relax policy", status: "todo", assigneeAgentId: agentId, executionWorkspaceSettings: { mode: "shared_workspace" },
projectWorkspaceId: selection === "configured_source" ? sourceId : null,
workspaceSelection: { version: 1, source: "explicit", selection: selection === "configured_source"
workspaceSelection: selection === "default" ? null : { version: 1, source: "explicit", selection: selection === "configured_source"
? { kind: selection, projectWorkspaceId: sourceId, mode: "shared" } : { kind: selection } },
});
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } });
@@ -245,7 +245,7 @@ const support = await getEmbeddedPostgresTestSupport();
const [retainedWorkspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, workspaceId));
expect(retainedWorkspace.metadata?._issuePrivacySources).toMatchObject({ [privateSource.id]: true });
// Reauthorization also applies after the second inventory has a durable pin.
await expect(prepareForAdmission(admission)).rejects.toThrow(/outside this actor's access/);
await expect(prepareForAdmission(admission)).rejects.toThrow(/Task workspace access is no longer available/);
expect(await readFile(path.join(prepared[0].cwd, "source.txt"), "utf8")).toBe("uncommitted work\n");
});
it("stages a root change without replacing active files or adding a wake", async () => {
@@ -7,8 +7,8 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces,
heartbeatRuns, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces,
heartbeatRuns, issueAccessGrants, issues, projects, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
@@ -32,12 +32,13 @@ const support = await getEmbeddedPostgresTestSupport();
let db: ReturnType<typeof createDb>;
let root: string;
beforeAll(async () => {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
database = await startEmbeddedPostgresTestDatabase("paperclip-repository-trust-");
db = createDb(database.connectionString);
root = await mkdtemp(path.join(tmpdir(), "paperclip-repository-trust-"));
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); });
afterAll(async () => { await database?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); });
afterAll(async () => { vi.unstubAllEnvs(); await database?.cleanup(); if (root) await rm(root, { recursive: true, force: true }); });
async function fixture() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
@@ -84,6 +85,161 @@ const support = await getEmbeddedPostgresTestSupport();
return { workspace, admission: { ...f.admission, cwd, issueId, runId, workspaceId: workspace.id }, actor: { ...f.actor, runId } };
}
async function standardPeer(f: Awaited<ReturnType<typeof fixture>>) {
const userId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Other sponsor", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await db.insert(agents).values({ id: agentId, companyId: f.companyId, name: "Other worker", adapterType: "process" });
await db.insert(companyMemberships).values([
{ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" },
{ companyId: f.companyId, principalType: "agent", principalId: agentId, membershipRole: "member", status: "active" },
]);
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Other task files", cwd: f.admission.cwd,
mode: "shared_workspace", strategyType: "operator_directory" }).returning();
await db.insert(issues).values({ id: issueId, companyId: f.companyId, title: "Other task", assigneeAgentId: agentId, executionWorkspaceId: workspace.id });
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId, responsibleUserId: userId, status: "running", contextSnapshot: { issueId } });
return { userId, agentId, issueId, runId, workspace,
actor: { ...f.actor, agentId, runId, onBehalfOfUserId: userId },
admission: { ...f.admission, agentId, runId, issueId, workspaceId: workspace.id, responsibleUserId: userId } };
}
async function privateTask(f: Awaited<ReturnType<typeof fixture>>) {
await db.update(issues).set({ visibility: "private", privacyRootIssueId: f.issueId, responsibleUserId: f.userId }).where(eq(issues.id, f.issueId));
}
async function grantPeer(f: Awaited<ReturnType<typeof fixture>>, peer: Awaited<ReturnType<typeof standardPeer>>) {
return db.insert(issueAccessGrants).values([
{ issueId: f.issueId, subjectType: "agent", subjectId: peer.agentId, source: "explicit", grantedByUserId: f.userId },
{ issueId: f.issueId, subjectType: "user", subjectId: peer.userId, source: "explicit", grantedByUserId: f.userId },
]).returning();
}
async function localPrivateClone(f: Awaited<ReturnType<typeof fixture>>, beforePublish?: () => Promise<void>) {
const source = path.join(root, `${f.issueId}-privacy-source`);
await mkdir(source);
const exec = promisify(execFileCallback);
await exec("git", ["init", "--initial-branch=main", source]);
await writeFile(path.join(source, "private.txt"), "Private task edits\n");
await exec("git", ["-C", source, "add", "."]);
await exec("git", ["-C", source, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
const actual = await vi.importActual<typeof import("../services/managed-repository-checkout.js")>("../services/managed-repository-checkout.js");
acquisition.materialize.mockImplementationOnce((cwd, input) => actual.materializeManagedProjectWorkspace(cwd, { ...input, localSource: source,
beforePublish: async cloneCwd => { await beforePublish?.(); await input.beforePublish(cloneCwd); } }));
}
it.each(["empty", "different_identity", "unreadable_alias", "anonymous_private_edits", "authorized_peer"] as const)(
"authorizes every physical repository source for a standard reader with %s", async scenario => {
const f = await fixture(), peer = await standardPeer(f);
if (scenario === "different_identity") await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
request: { repository: { kind: "url", url: "https://github.com/public/other" }, requestKey: randomUUID() } });
await privateTask(f);
const intent = await f.request(scenario === "anonymous_private_edits" ? { kind: "url", url: "https://github.com/public/source" } : { kind: "catalog", id: "private-repo" });
await localPrivateClone(f);
const [prepared] = await f.service.prepareForAdmission(f.admission);
expect(await readFile(path.join(prepared.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
if (scenario === "unreadable_alias") {
const alias = path.join(root, `${f.issueId}-private-alias`);
await symlink(f.admission.cwd, alias);
await db.update(executionWorkspaces).set({ cwd: alias }).where(eq(executionWorkspaces.id, f.workspace.id));
// Published path evidence must also bootstrap provenance for old rows.
await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
const actual = fs.realpath.bind(fs);
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
if (String(pathname) === alias) throw Object.assign(new Error("Private alias unavailable"), { code: "EACCES" });
return actual(pathname);
});
}
if (scenario === "authorized_peer") {
await grantPeer(f, peer);
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
} else await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
expect((await f.service.list(f.companyId, f.workspace.id))[0].id).toBe(intent.operationId);
expect(acquisition.materialize).toHaveBeenCalledOnce();
},
);
it("rechecks retained own-workspace privacy without adding an empty-admission membership gate", async () => {
const f = await fixture();
await db.delete(companyMemberships).where(eq(companyMemberships.companyId, f.companyId));
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(f.admission)).resolves.toEqual([]);
const [privateSource] = await db.insert(issues).values({ companyId: f.companyId, title: "Other private writer", visibility: "private",
executionWorkspaceId: f.workspace.id }).returning();
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, privateSource.id));
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/workspace access/);
});
it("does not publish managed bytes to a retained low-trust reader even with private source grants", async () => {
const f = await fixture(), peer = await standardPeer(f);
await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId,
parentId: f.issueId }).where(eq(issues.id, peer.issueId));
await db.insert(issueAccessGrants).values([
{ issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId },
{ issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId },
]);
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId));
await expect(f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } })).rejects.toThrow(/anonymous public URLs only/);
expect(await f.service.list(f.companyId, peer.workspace.id)).toEqual([]);
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it("retains private direct files from an admitted workspace with no repository inventory", async () => {
const f = await fixture(), peer = await standardPeer(f);
await privateTask(f);
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await writeFile(path.join(f.admission.cwd, "private-direct.txt"), "Private direct task output");
await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-direct-source"), metadata: {} }).where(eq(executionWorkspaces.id, f.workspace.id));
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
await grantPeer(f, peer);
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
expect(await readFile(path.join(f.admission.cwd, "private-direct.txt"), "utf8")).toBe("Private direct task output");
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it("blocks a future private source after an empty standard reader was admitted and moved", async () => {
const f = await fixture(), peer = await standardPeer(f);
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-standard-reader"), metadata: {}, status: "archived" }).where(eq(executionWorkspaces.id, peer.workspace.id));
await privateTask(f);
await expect(executionWorkspaceRepositoryService(db).request({ companyId: f.companyId, issueId: f.issueId, actor: f.actor,
request: { repository: { kind: "catalog", id: "private-repo" }, requestKey: randomUUID() } })).rejects.toThrow(/workspace access|authorization boundary/);
expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]);
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it("rechecks configured-source publication against retained readers after grant revocation", async () => {
const f = await fixture(), peer = await standardPeer(f);
await privateTask(f);
const grants = await grantPeer(f, peer);
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grants[0].id));
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/workspace access|authorization boundary/);
await expect(f.service.guardForPublication({ ...f.admission, workspaceId: peer.workspace.id })).rejects.toThrow(/publication binding/);
expect(acquisition.materialize).not.toHaveBeenCalled();
expect(acquisition.catalog).not.toHaveBeenCalled();
});
it("fences pending private clones and rechecks granted readers before actual publication", async () => {
const f = await fixture(), peer = await standardPeer(f);
await privateTask(f);
const intent = await f.request({ kind: "catalog", id: "private-repo" });
let release!: () => void, started!: () => void;
const gate = new Promise<void>(resolve => { release = resolve; });
const staged = new Promise<void>(resolve => { started = resolve; });
await localPrivateClone(f, async () => { started(); await gate; });
const preparation = f.service.prepareForAdmission(f.admission);
const outcome = preparation.then(value => ({ value, error: null }), error => ({ value: null, error }));
try {
await Promise.race([staged, outcome.then(() => { throw new Error("Clone ended before publication barrier"); })]);
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
await grantPeer(f, peer);
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.issueId, f.issueId));
} finally { release(); }
expect((await outcome).error).toBeTruthy();
expect(await fs.lstat(path.join(f.admission.cwd, intent.repository.relativePath)).catch(() => null)).toBeNull();
});
it.each(["agent", "issue", "project", "workspace_source_project", "run"] as const)(
"rejects catalog acquisition from raw %s low-trust policy", async source => {
const f = await fixture();
@@ -314,6 +470,7 @@ const support = await getEmbeddedPostgresTestSupport();
it("reserves a legacy low-trust recovery folder after its task binding moves", async () => {
const f = await fixture();
const peer = await lowTrustPeer(f);
await db.update(issues).set({ parentId: peer.admission.issueId }).where(eq(issues.id, f.issueId));
await db.update(issues).set({ executionWorkspaceId: null }).where(eq(issues.id, peer.admission.issueId));
await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-recovery"), metadata: {}, status: "archived" })
.where(eq(executionWorkspaces.id, peer.workspace.id));
@@ -343,6 +500,7 @@ const support = await getEmbeddedPostgresTestSupport();
const alias = path.join(root, `${f.issueId}-alias`);
await symlink(f.admission.cwd, alias);
const peer = await lowTrustPeer(f, alias);
await db.update(issues).set({ parentId: peer.admission.issueId }).where(eq(issues.id, f.issueId));
const outcomes = await Promise.allSettled([
f.service.prepareForAdmission(peer.admission),
f.request({ kind: "catalog", id: "private-repo" }),
@@ -364,6 +522,8 @@ const support = await getEmbeddedPostgresTestSupport();
// A standard task's initially anonymous intent may later appear in its catalog.
const pending = await f.request({ kind: "url", url: "https://github.com/public/later-catalog" });
const peer = await lowTrustPeer(f);
// This source task belongs to the reader's trusted task subtree.
await db.update(issues).set({ parentId: peer.admission.issueId }).where(eq(issues.id, f.issueId));
await f.service.prepareForAdmission(peer.admission);
await db.update(executionWorkspaces).set({ cwd: path.join(root, "moved-anonymous-workspace"), metadata: {}, status: "archived" })
.where(eq(executionWorkspaces.id, peer.workspace.id));
@@ -71,7 +71,7 @@ export function agentDirectoryWorkingCopyService(db: Db, get: (companyId: string
// while plugin capability discovery is cold after controller restart.
runtimeRootDir: sandboxTransferRoot(row, target.remoteCwd),
syncWorkspace: true, workspaceInboundMode: recovering ? "adopt_remote" : undefined,
workspaceBaseline: baseline(row), workspaceGitSnapshot: null, workspaceFileMode: "all",
workspaceBaseline: baseline(row), workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all",
workspaceExclude: [".paperclip-runtime", ".paperclip-runtime/**"] });
}
async function observe(row: Copy, target?: AdapterExecutionTarget | null) {
@@ -107,7 +107,7 @@ export async function captureAgentFileCheckpoint(input: {
stats = JSON.parse(result.stdout.trim()) as AgentFileCheckpointStats;
await fs.rm(cacheDir, { recursive: true, force: true });
restore = await prepareAdapterExecutionTargetRuntime({ target, runId: input.runId, adapterKey: `agent-checkpoint-download-${path.basename(directory)}`, workspaceLocalDir: directory,
workspaceRemoteDir: remoteDirectory!, syncWorkspace: true, workspaceInboundMode: "adopt_remote", workspaceBaseline: { exclude: [], entries: new Map() }, workspaceGitSnapshot: null, workspaceFileMode: "all" });
workspaceRemoteDir: remoteDirectory!, syncWorkspace: true, workspaceInboundMode: "adopt_remote", workspaceBaseline: { exclude: [], entries: new Map() }, workspaceGitSnapshot: null, workspaceRepositories: [], workspaceFileMode: "all" });
await restore.restoreWorkspace();
}
return { directory, manifest: await validateAgentFileCheckpoint(directory, input.previous), stats, cleanup };
@@ -5,13 +5,13 @@ import fs from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import path from "node:path";
import { and, eq, isNotNull, or, sql } from "drizzle-orm";
import { activityLog, agents, heartbeatRuns, projects, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db";
import { activityLog, agents, heartbeatRuns, projects, executionWorkspaceRepositoryAccess, executionWorkspaceRepositoryRoots, executionWorkspaceRepositories, executionWorkspaces, issues, type Db } from "@paperclipai/db";
import { prepareWorkspaceRepositorySchema, type PrepareWorkspaceRepository, type ProjectRepository } from "@paperclipai/shared";
import { withDirectoryPublicationLock } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { conflict, forbidden, notFound } from "../errors.js";
import { parseNativeExecutionInput } from "../vendor/paperclip-runner/index.js";
import { assertTaskWorkspaceAccess } from "./task-workspace-source-access.js";
import { authorizationService, canActorReadExecutionWorkspace, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { authorizationService, canActorReadExecutionWorkspace, executionWorkspaceReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { captureRunIdentity } from "./run-identity.js";
import { toolAccessService } from "./tool-access.js";
@@ -168,6 +168,120 @@ export function executionWorkspaceRepositoryService(db: Db) {
if (!existing) await tx.insert(executionWorkspaceRepositoryRoots).values({ companyId, canonicalCwd, rootKey, mode });
return canonicalCwd;
}
type RootReader = { runId: string; issueId: string; agentId: string };
async function authorizeRootAccess(tx: DbTransaction, input: {
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; preserveOwnAdmission?: boolean;
}) {
const canonicalCwd = await canonicalRepositoryRoot(input.cwd);
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)));
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
const sourceIds = new Set(entries.filter(entry => entry.kind === "source").map(entry => entry.scopeId));
if (input.sourceWorkspaceId) sourceIds.add(input.sourceWorkspaceId);
if (!entries.some(entry => entry.kind === "reader")) {
// Bootstrap ordinary task files as well as acquisition receipts. Later
// admissions retain their sources here before they can write this root.
const workspaces = await tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces)
.where(eq(executionWorkspaces.companyId, input.companyId));
for (const workspace of workspaces) {
if (!workspace.cwd) continue;
const peerRoot = await canonicalRepositoryRoot(workspace.cwd).catch((error: NodeJS.ErrnoException) => {
if (["ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null;
throw error;
});
if (peerRoot === canonicalCwd) sourceIds.add(workspace.id);
}
}
const inventory = await tx.select({ workspaceId: executionWorkspaces.id, cwd: executionWorkspaces.cwd,
identity: executionWorkspaceRepositories.repositoryIdentity }).from(executionWorkspaceRepositories)
.innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId))
.where(and(eq(executionWorkspaceRepositories.companyId, input.companyId), eq(executionWorkspaces.companyId, input.companyId)));
const namespace = path.join(canonicalCwd, ".paperclip-repositories");
const namespaceEntry = await fs.lstat(namespace).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
const publishedNames = new Set<string>();
if (namespaceEntry) {
if (!namespaceEntry.isDirectory() || namespaceEntry.isSymbolicLink() || await fs.realpath(namespace) !== namespace) {
throw forbidden("Repository namespace has no verifiable contained ownership");
}
// Configured project siblings have a separate source owner. This child
// aggregate owns only task-repo-* publications and their retained provenance.
for (const name of await fs.readdir(namespace)) if (name.startsWith("task-repo-")) publishedNames.add(name);
}
const recognizedNames = new Set<string>();
for (const peer of inventory) {
const name = path.basename(taskRepositoryRelativePath(peer.identity));
const published = publishedNames.has(name);
if (published) recognizedNames.add(name);
// Once a reader was admitted, future sources must be registered through
// this fence. Still reconcile physical publications from older versions.
if (published || (!entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd))) {
sourceIds.add(peer.workspaceId);
}
}
if ([...publishedNames].some(name => !recognizedNames.has(name))) throw forbidden("Published repository has no retained workspace provenance");
const readers = entries.filter(entry => entry.kind === "reader");
if (input.reader && !readers.some(reader => reader.scopeId === input.reader!.runId)) {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.reader.runId), eq(heartbeatRuns.companyId, input.companyId)));
readers.push({ id: randomUUID(), companyId: input.companyId, rootKey, canonicalCwd, kind: "reader", scopeId: input.reader.runId,
issueId: input.reader.issueId, agentId: input.reader.agentId, responsibleUserId: run?.responsibleUserId ?? null, createdAt: new Date(), updatedAt: new Date() });
}
// Heartbeat owns ordinary admission to this task's already-authorized
// folder. Avoid adding acquisition permissions when it is the only task
// and has no acquisition. Any other task/source requires the full closure.
const ownAdmissionOnly = input.preserveOwnAdmission && input.reader && input.sourceWorkspaceId
&& sourceIds.size === 1 && sourceIds.has(input.sourceWorkspaceId)
&& readers.every(reader => reader.issueId === input.reader!.issueId);
for (const reader of readers) {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, reader.scopeId), eq(heartbeatRuns.companyId, input.companyId)));
const [agent] = reader.agentId ? await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, reader.agentId), eq(agents.companyId, input.companyId))) : [];
const [issue] = reader.issueId ? await tx.select().from(issues).where(and(eq(issues.id, reader.issueId), eq(issues.companyId, input.companyId))) : [];
if (!run || !agent || !issue || run.agentId !== reader.agentId || run.responsibleUserId !== reader.responsibleUserId
|| (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== reader.issueId) {
throw forbidden("Repository root retains a reader whose identity is no longer available");
}
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: reader.agentId!, runId: reader.scopeId, onBehalfOfUserId: reader.responsibleUserId === "local-board" ? null : reader.responsibleUserId };
if (ownAdmissionOnly) {
const [ownSource] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.sourceWorkspaceId!),
// Check both privacy principals without repeating the membership
// admission gate that heartbeat already owns for this exact task.
await executionWorkspaceReadSqlCondition(tx, { ...actor, onBehalfOfUserId: null }),
actor.onBehalfOfUserId ? await executionWorkspaceReadSqlCondition(tx, {
type: "board", userId: actor.onBehalfOfUserId, ignoreInstanceAdmin: true,
}) : undefined));
if (!ownSource) throw forbidden("Task workspace access is no longer available");
} else for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, actor, input.companyId, sourceId);
const sourceWorkspaces = sourceIds.size ? await tx.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, input.companyId), or(...[...sourceIds].map(id => eq(executionWorkspaces.id, id))))) : [];
for (const sourceWorkspace of sourceWorkspaces.length ? sourceWorkspaces : [null]) {
if (await requiresAnonymousAcquisition(tx, { issue, workspace: sourceWorkspace, agentId: reader.agentId!, runId: reader.scopeId })) {
// Retained readers cannot gain managed bytes after their task, agent,
// run, or any retained physical source becomes anonymous-only.
await reserveRootPolicy(tx, input.companyId, canonicalCwd, "anonymous_only");
}
}
}
if (input.actor) for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, sourceId);
for (const sourceId of sourceIds) {
if (!entries.some(entry => entry.kind === "source" && entry.scopeId === sourceId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "source", scopeId: sourceId });
}
}
for (const reader of readers) {
if (!entries.some(entry => entry.kind === "reader" && entry.scopeId === reader.scopeId)) await tx.insert(executionWorkspaceRepositoryAccess).values(reader);
}
}
async function reserveRootAccess(input: Parameters<typeof authorizeRootAccess>[1]) {
return db.transaction(async tx => {
await lockRootPolicies(tx, input.companyId);
await authorizeRootAccess(tx, input);
});
}
async function reserveAdmissionRoot(companyId: string, cwd: string, mode: "anonymous_only" | "managed") {
return db.transaction(async tx => {
await lockRootPolicies(tx, companyId);
@@ -215,6 +329,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
if (!workspace.cwd) throw conflict("The task workspace has no directory for repository preparation");
await reserveRootPolicy(tx, input.companyId, workspace.cwd, anonymousOnly ? "anonymous_only" : "managed");
}
if (!workspace.cwd) throw conflict("The task workspace has no directory for repository preparation");
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor });
const existing = await tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`)));
for (const row of existing) {
assertAcquisitionReceipt(row, anonymousOnly);
@@ -238,6 +354,20 @@ 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)));
}
/** 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 }) {
await db.transaction(async tx => {
await lockRootPolicies(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 });
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: workspace.id, reader: input });
});
}
/** 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)));
@@ -262,6 +392,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
else if ((await list(input.companyId, input.workspaceId)).some(row => row.catalogRepositoryId)) {
await reserveAdmissionRoot(input.companyId, input.cwd, "managed");
}
await reserveRootAccess({ companyId: input.companyId, cwd: input.cwd, reader: input,
...(workspace ? { sourceWorkspaceId: workspace.id } : {}),
preserveOwnAdmission: !(await list(input.companyId, input.workspaceId)).length });
}
/** Logical rows may deliberately name one physical folder. Reuse bytes only
* after validating every published owner's retained privacy and source intent. */
@@ -300,6 +433,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
// Ordinary admission without acquisitions is owned by heartbeat, not this mutation service.
if (!rows.length) {
if (anonymousOnly) await reserveAdmissionRoot(input.companyId, input.cwd, "anonymous_only");
await reserveRootAccess({ companyId: input.companyId, cwd: input.cwd, reader: input,
sourceWorkspaceId: workspace.id, preserveOwnAdmission: true });
return [];
}
await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true, issueId: input.issueId });
@@ -315,6 +450,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
const reservedRoot = await reserveAdmissionRoot(input.companyId, root, anonymousOnly ? "anonymous_only" : "managed");
if (reservedRoot !== root) throw conflict("Repository workspace root changed during admission");
}
await reserveRootAccess({ companyId: input.companyId, cwd: root, sourceWorkspaceId: input.workspaceId, reader: input });
const repositoryRoot = path.join(root, ".paperclip-repositories");
await fs.mkdir(repositoryRoot, { recursive: true });
if ((await fs.lstat(repositoryRoot)).isSymbolicLink() || await fs.realpath(repositoryRoot) !== repositoryRoot) throw conflict("Repository directory escapes the task workspace");
@@ -388,6 +524,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
repoRef: authorizedPin ?? (row.requestedRef === "HEAD" ? null : row.requestedRef),
resolveGitAuth: source.catalogRepositoryId ? resolveAuth : async () => anonymous,
beforePublish: async cloneCwd => {
await reserveRootAccess({ companyId: input.companyId, cwd: root, sourceWorkspaceId: input.workspaceId, reader: input });
const pinnedCommit = (await execFile("git", ["-C", cloneCwd, "rev-parse", "HEAD"], { timeout: 30_000 })).stdout.trim();
await fs.writeFile(path.join(cloneCwd, ".git", "paperclip-workspace-owner.json"), JSON.stringify({ version: 1, repositoryId: row.id, pinnedCommit }), { flag: "wx", mode: 0o600 });
},
@@ -411,5 +548,5 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
return prepared;
}
return { request, list, prepareForAdmission, reserveForRecovery };
return { request, list, prepareForAdmission, reserveForRecovery, guardForPublication };
}
@@ -2153,6 +2153,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
requestTaskRepository: executionWorkspaceRepositoryService(db).request,
listTaskRepositories: executionWorkspaceRepositoryService(db).list,
prepareTaskRepositoriesForAdmission: executionWorkspaceRepositoryService(db).prepareForAdmission,
guardTaskRepositoryRootForPublication: executionWorkspaceRepositoryService(db).guardForPublication,
reserveTaskRepositoryRootForRecovery: executionWorkspaceRepositoryService(db).reserveForRecovery,
resolveSourceWorkspaceStrategy, validateSelection, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, prepareTaskWorkspaceUpdate, assertTaskWorkspaceUpdateAccess,
listOverview: async (
+15 -3
View File
@@ -2397,8 +2397,6 @@ export function heartbeatService(
const [selectedWorkspaceSource] = !boundSourceWorkspace && issueContext?.projectWorkspaceId
? await db.select({ projectId: projectWorkspaces.projectId }).from(projectWorkspaces)
.where(and(eq(projectWorkspaces.id, issueContext.projectWorkspaceId), eq(projectWorkspaces.companyId, agent.companyId))) : [];
const executionProjectId = boundSourceWorkspace ? boundSourceWorkspace.projectId
: taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory" ? null : selectedWorkspaceSource?.projectId ?? issueContext?.projectId ?? contextProjectId;
const loadProjectContext = async (projectId: string | null | undefined) => projectId
? await db
.select({
@@ -2410,6 +2408,8 @@ export function heartbeatService(
.where(and(
eq(projectWorkspaces.projectId, projects.id),
eq(projectWorkspaces.companyId, agent.companyId),
sql`(nullif(btrim(${projectWorkspaces.cwd}), '') is not null
or nullif(btrim(${projectWorkspaces.repoUrl}), '') is not null)`,
)),
).mapWith(Boolean),
env: projects.env,
@@ -2426,6 +2426,11 @@ export function heartbeatService(
: null;
const taskProjectId = issueContext ? issueContext.projectId : contextProjectId;
const taskProjectContext = await loadProjectContext(taskProjectId);
// Organizational placement supplies environment and trust, not an
// implicit filesystem source when that project has no configured files.
const executionProjectId = boundSourceWorkspace ? boundSourceWorkspace.projectId
: taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory" ? null
: selectedWorkspaceSource?.projectId ?? (taskProjectContext?.hasWorkspace ? taskProjectId : null);
if (issueContext) {
if (taskProjectId) context.projectId = taskProjectId;
else delete context.projectId;
@@ -2540,9 +2545,16 @@ export function heartbeatService(
);
issueContext = { ...issueContext, responsibleUserId };
}
// Legacy unbound tasks still inherit explicit organizational workspace
// requirements even when that project has no files to use as a source.
// A typed choice or durable binding keeps its own source policy.
const workspacePolicyContext = workspaceProjectContext ?? (
!boundSourceWorkspace && !taskWorkspaceIntentRow?.intent && !issueContext?.projectWorkspaceId
? taskProjectContext : null
);
const parsedProjectExecutionWorkspacePolicy =
parseProjectExecutionWorkspacePolicy(
workspaceProjectContext?.executionWorkspacePolicy,
workspacePolicyContext?.executionWorkspacePolicy,
);
const projectExecutionWorkspacePolicy =
applyDefaultIsolatedExecutionWorkspacePolicy({
@@ -1,7 +1,8 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile, access } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile, access } 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";
@@ -114,6 +115,37 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
expect(input.controllerLease.assertOwned).toHaveBeenCalledTimes(2);
});
it("checks physical root admission before publishing configured sibling repositories", async () => {
const { input, source, issue } = await fixture();
await db.insert(projectWorkspaces).values({ companyId: issue.companyId, projectId: issue.projectId!,
name: "Sibling repository", repoUrl: pathToFileURL(source).href });
vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission")
.mockRejectedValue(new Error("Physical root reader cannot access this source"));
const acquire = vi.spyOn(input.services.envOrchestrator, "acquireForRun");
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Physical root reader cannot access this source");
await expect(access(join(source, ".paperclip-repositories"))).rejects.toMatchObject({ code: "ENOENT" });
expect(acquire).not.toHaveBeenCalled();
});
it("rechecks physical root access after configured clone and before publication", async () => {
const { input, source, issue } = await fixture();
await db.insert(projectWorkspaces).values({ companyId: issue.companyId, projectId: issue.projectId!,
name: "Sibling repository", repoUrl: pathToFileURL(source).href });
const namespace = join(source, ".paperclip-repositories");
const guard = vi.spyOn(input.services.executionWorkspacesSvc, "guardTaskRepositoryRootForPublication")
.mockImplementation(async () => {
const candidates = await readdir(namespace);
expect(candidates).toHaveLength(1);
expect(candidates[0]).toContain(".clone-");
expect(await readFile(join(namespace, candidates[0]!, "README.md"), "utf8")).toBe("source content\n");
throw new Error("Physical root access revoked during clone");
});
await expect(prepareHeartbeatWorkspace(db, input)).rejects.toThrow("Physical root access revoked during clone");
expect(guard).toHaveBeenCalledOnce();
expect(await readdir(namespace)).toEqual([]);
expect(await leases(input.run.id)).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,
@@ -126,7 +126,7 @@ export interface HeartbeatWorkspacePreparationInput {
};
services: {
envOrchestrator: Pick<ReturnType<typeof environmentRunOrchestrator>, "resolveEnvironment" | "acquireForRun" | "realizeForRun">;
executionWorkspacesSvc: Pick<ReturnType<typeof executionWorkspaceService>, "create" | "update" | "bindTaskWorkspace" | "prepareTaskRepositoriesForAdmission" | "reserveTaskRepositoryRootForRecovery">;
executionWorkspacesSvc: Pick<ReturnType<typeof executionWorkspaceService>, "create" | "update" | "bindTaskWorkspace" | "prepareTaskRepositoriesForAdmission" | "guardTaskRepositoryRootForPublication" | "reserveTaskRepositoryRootForRecovery">;
workspaceOperationsSvc: Pick<ReturnType<typeof workspaceOperationService>, "createRecorder">;
resolveWorkspaceForRun: WorkspaceResolver["resolveWorkspaceForRun"];
resolveReusedGitWorkspaceAnchor: WorkspaceResolver["resolveReusedGitWorkspaceAnchor"];
@@ -860,6 +860,12 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
}
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
const projectRepositoryPaths: string[] = [];
if (persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
const taskRepositories = await executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId,
issueId, workspaceId: persistedExecutionWorkspace.id, cwd: executionWorkspace.cwd,
agentId: agent.id, runId: run.id, responsibleUserId });
projectRepositoryPaths.push(...taskRepositories.map(repository => repository.relativePath));
}
if (executionWorkspace.projectId
&& (resolvedWorkspace.source === "project_primary" || Boolean(reusableExistingExecutionWorkspace?.projectWorkspaceId))
&& !resolvedWorkspace.baseCwdFallback && !persistedNativeExecutionInput) {
@@ -872,6 +878,11 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
anchorRepoUrl: executionWorkspace.repoUrl,
workspaces: repositoryRows,
resolveGitAuth: workspaceGitAuthProvider,
beforePublish: persistedExecutionWorkspace && issueId && !nativeRecoveryExecutionWorkspaceId
? () => executionWorkspacesSvc.guardTaskRepositoryRootForPublication({
companyId: agent.companyId, issueId, workspaceId: persistedExecutionWorkspace!.id,
cwd: executionWorkspace.cwd, agentId: agent.id, runId: run.id,
}) : undefined,
});
const paths = new Map(repositories.map((repo) => [repo.workspaceId, repo.cwd]));
projectRepositoryPaths.push(...repositories.map((repo) => path.relative(executionWorkspace.cwd, repo.cwd)));
@@ -885,12 +896,6 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
repoRef: readNonEmptyString(source.repoRef),
}));
}
if (persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
const taskRepositories = await executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId,
issueId, workspaceId: persistedExecutionWorkspace.id, cwd: executionWorkspace.cwd,
agentId: agent.id, runId: run.id, responsibleUserId });
projectRepositoryPaths.push(...taskRepositories.map(repository => repository.relativePath));
}
if (persistedExecutionWorkspace) {
context.executionWorkspaceId = persistedExecutionWorkspace.id;
await db
+7 -4
View File
@@ -500,6 +500,7 @@ export async function prepareProjectRepositoryWorkspaces(input: {
anchorRepoUrl: string | null;
workspaces: Array<Pick<typeof projectWorkspaces.$inferSelect, "id" | "repoUrl" | "repoRef"> & { cwd?: string | null }>;
resolveGitAuth?: GitRemoteAuthProvider | null;
beforePublish?: () => 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)] : []);
@@ -526,7 +527,7 @@ export async function prepareProjectRepositoryWorkspaces(input: {
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 });
const result = await materializeManagedProjectWorkspace(cwd, { repoUrl, repoRef: workspace.repoRef, localSource, resolveGitAuth: input.resolveGitAuth, beforePublish: input.beforePublish });
if (result.warning) throw new Error(result.warning);
results.push({ workspaceId: workspace.id, cwd, repoUrl, repoRef: workspace.repoRef });
}
@@ -3591,12 +3592,12 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
const executionEnvironmentDriver = opts?.executionEnvironmentDriver ?? null;
const issueId =
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
const responsibleUserId = readNonEmptyString(context.responsibleUserId)
?? readNonEmptyString(context.responsible_user_id);
const resolveGitAuth = createGitRemoteAuthProvider(db, agent.companyId, {
issueId,
heartbeatRunId: readNonEmptyString(context.executionIdentityRunId),
responsibleUserId:
readNonEmptyString(context.responsibleUserId) ??
readNonEmptyString(context.responsible_user_id),
responsibleUserId,
agentId: agent.id,
});
const { additionalWorkspaces, warnings, failures } =
@@ -3615,6 +3616,8 @@ export function createHeartbeatWorkspaceResolver(db: Db) {
agentId: agent.id,
companyId: agent.companyId,
source: "agent_key",
runId: readNonEmptyString(context.executionIdentityRunId),
onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId,
},
issues: issueService(db),
projects: projectService(db),