Enforce shared-root write authority and recover low-disk exports safely

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 11:27:36 -05:00
1 parent 52ea8711ef
commit 1b98e5df2f
10 files changed
+352 -36

No files matched your search

+1 -1
View File
@@ -753,6 +753,6 @@ Migration [0334_right_hannibal_king.sql](../packages/db/src/migrations/0334_righ
Migration [0335_oval_screwball.sql](../packages/db/src/migrations/0335_oval_screwball.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 [0336_chilly_hulk.sql](../packages/db/src/migrations/0336_chilly_hulk.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. 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 [0336_chilly_hulk.sql](../packages/db/src/migrations/0336_chilly_hulk.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.
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,7 +43,7 @@ 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. Known reservations do not rescan historical workspace paths. 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.
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.
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. File listing, previews and downloads use the same read-only physical-source resolution and also check current configured project-folder aliases. Those reads create no policy or reader records. Explicit child source choices, including clearing inherited linkage with null, take precedence over parent workspace inheritance; omitted choices retain inheritance and normal project defaults.
@@ -61,7 +61,7 @@ Preserve simultaneous local/SSH tasks in an explicitly configured identical phys
Concurrent remote sharing requires proven common physical placement, separate session authority and coherent snapshot capability. Unsupported new concurrent sharing returns a typed unavailable result; do not instantiate separate clones and label them shared. Already-admitted descriptors remain recoverable unchanged. Local authoritative roots need no replica copyback; SSH retains its actual existing persistence contract.
Sparse filesystem checkpoints use disk-backed full manifests, changed bytes/deletions, safe-link and existing ignore semantics, and separate nested Git history. Capture still enumerates and hashes files with stable-read validation; a metadata hash cache is deferred. No-change sends zero file-content payload where capability is supported; manifest metadata still transfers; unsupported transport uses explicit confined full fallback. Reuse immutable durable seed generations, not only warm sandbox contents; reuse still verifies archive digests. Existing run references pin seeds/deltas until all admitted runs/recovery obligations release them. Retain the final host rescan because the run ownership fence does not exclude external writers to shared folders; do not reuse a stale post-merge manifest.
Sparse filesystem checkpoints use disk-backed full manifests, changed bytes/deletions, safe-link and existing ignore semantics, and separate nested Git history. Capture still enumerates and hashes files with stable-read validation; a metadata hash cache is deferred. No-change sends zero file-content payload where capability is supported; manifest metadata still transfers; unsupported transport uses explicit confined full fallback. Sparse capture capacity failures before outbound data is read also use the existing confined full transfer, so a tiny output can recover when the sandbox cannot preserve the sparse manifest reserve. Returned manifest, path, digest and payload validation failures remain errors and never trigger fallback. Full checkpoint exports validate the completed staging root before merge, reject unsupported entries, and omit absolute or escaping links without following them; safe relative links remain supported. Reuse immutable durable seed generations, not only warm sandbox contents; reuse still verifies archive digests. Existing run references pin seeds/deltas until all admitted runs/recovery obligations release them. Retain the final host rescan because the run ownership fence does not exclude external writers to shared folders; do not reuse a stale post-merge manifest.
Seed recovery deliberately uses **no delta chain** in this implementation. An unchanged generation reuses its verified content-addressed immutable full seed; a changed generation creates a fresh full seed. Sparse output transfer is separate from seed recovery. This revises the sketch's 16-delta compaction proposal: composing recovery generations would add a new failure surface before the simpler cache has been qualified. Existing references retain old seeds; no automatic eviction/deletion policy is introduced. Optional cache publication is capped at 16 generations/1 GiB per workspace and 256 generations/256 workspace directories/8 GiB per company. A company-scoped filesystem lock serializes quota checks and publication across workspace roots. Archive and receipt bytes, corrupt entries and abandoned pending generations count against capacity; publication also preserves the manifest storage free-space reserve (256 MiB by default), accounting for a full-copy fallback. Saturation or cache failure skips publication and keeps the already-written per-run recovery seed. Existing over-capacity caches stop growing without deleting admitted evidence. This deliberately trades cache hit rate after saturation for bounded additional storage and unchanged recovery semantics; no global cross-company quota is added. Changed input generations still pay full snapshot creation. New cache entries are reconstructed and checked against the admitted baseline before publication, so concurrent writes cannot label an archive as the wrong generation; this adds cold verification work. A corrupted cache entry remains a miss and falls back to a fresh seed; automatic cache repair and garbage collection are deferred. The existing Git seed contract captures the selected branch/HEAD, not unrelated refs.
@@ -368,6 +368,16 @@ function isWorkspaceGitScanSaturatedError(error: unknown): boolean {
);
}
// Remote command transports may preserve only stderr, not the original error
// code. This is used only for capture-command failures, never returned data.
function isCheckpointCaptureCapacityError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const failure = error as { code?: unknown; message?: unknown; stderr?: unknown };
if (["ENOSPC", "EDQUOT", "SQLITE_FULL"].includes(String(failure.code))) return true;
const diagnostic = [failure.message, failure.stderr].filter(value => typeof value === "string").join("\n");
return /Workspace checkpoint storage is below its free-space reserve|\b(?:ENOSPC|EDQUOT|SQLITE_FULL)\b|database or disk is full/i.test(diagnostic);
}
/**
* Resolve a referenced project's Git-ignored paths ONCE, before any staging
* site runs. Called once per project (see `execute.ts`); the sandbox lane,
@@ -908,6 +918,31 @@ async function* walkDirectory(root: string, relative = "", childrenFirst = false
}
}
// Native providers validate their own transfers, but full-export confinement
// must also hold for the returned host tree before baseline merge can write.
async function confineFullWorkspaceExport(root: string): Promise<void> {
const unsafe = () => Object.assign(new Error("Unsafe workspace export directory"), { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" });
if (!(await fs.lstat(root)).isDirectory()) throw unsafe();
const canonicalRoot = await fs.realpath(root);
const walk = async (directory: string): Promise<void> => {
if (await fs.realpath(directory) !== directory) throw unsafe();
for await (const entry of await fs.opendir(directory)) {
const entryPath = path.join(directory, entry.name);
const stats = await fs.lstat(entryPath);
if (stats.isDirectory()) await walk(entryPath);
else if (stats.isSymbolicLink()) {
const target = await fs.readlink(entryPath);
const resolved = path.resolve(directory, target);
// Match sparse capture: omit unsafe links, preserving unrelated bytes.
if (path.isAbsolute(target) || (resolved !== canonicalRoot && !resolved.startsWith(`${canonicalRoot}${path.sep}`))) {
await fs.unlink(entryPath);
}
} else if (!stats.isFile()) throw unsafe();
}
};
await walk(canonicalRoot);
}
async function copyWorkspaceEntry(sourceRoot: string, targetRoot: string, relative: string): Promise<void> {
const sourcePath = path.join(sourceRoot, relative);
const targetPath = path.join(targetRoot, relative);
@@ -2010,7 +2045,6 @@ export async function prepareSandboxManagedRuntime(input: {
{ timeoutMs: 15_000 },
).then(() => true, () => false);
}
if (input.workspaceCheckpoint && !sparseSupported) await input.onWorkspaceCheckpoint?.({ mode: "full", scannedEntries: null, changedFiles: null, payloadBytes: null }).catch(() => undefined);
if (sparseSupported) {
const checkpointBaseline = repositories.length === 0 ? baselineSnapshot! : await selectDirectorySnapshot(baselineSnapshot!, {
omit: repositories.map((repo) => repo.path), exclude: workspaceRestoreExclude, ignoredPaths,
@@ -2031,23 +2065,36 @@ export async function prepareSandboxManagedRuntime(input: {
assertSyncOperationsConfined(inbound, { sourceRoots: [tempDir], targetRoots: [runtimeRootDir] });
await input.client.syncIn!(inbound);
try {
await input.client.run(["node", remoteHelper, workspaceRemoteDir, remoteManifest, remoteOutput, JSON.stringify(workspaceRestoreExclude)].map(shellQuote).join(" "), { timeoutMs: input.spec.timeoutMs });
const output = path.join(tempDir, "checkpoint");
const outbound: SandboxSyncOperation[] = [{ operationId: nextSyncOperationId(), files: [
{ sourcePath: remoteOutput, targetPath: output, kind: "directory" },
] }];
assertSyncOperationsConfined(outbound, { sourceRoots: [runtimeRootDir], targetRoots: [tempDir] });
await withWorkspaceRestoreStep("workspace_transfer", () => input.client.syncOut!(outbound));
checkpointSnapshot = readCheckpointSnapshot(path.join(output, "manifest.sqlite"), checkpointBaseline);
await fs.rename(path.join(output, "payload"), extractedDir);
const metrics = await validateCheckpointPayload({ baseline: checkpointBaseline, snapshot: checkpointSnapshot, payload: extractedDir });
await input.onWorkspaceCheckpoint?.(metrics).catch(() => undefined);
let captured = false;
try {
await input.client.run(["node", remoteHelper, workspaceRemoteDir, remoteManifest, remoteOutput, JSON.stringify(workspaceRestoreExclude)].map(shellQuote).join(" "), { timeoutMs: input.spec.timeoutMs });
captured = true;
} catch (error) {
// A sparse capture needs extra remote disk for its manifest
// and payload. Full transfer needs neither; it still passes
// the normal confinement and baseline-merge validation.
if (!isCheckpointCaptureCapacityError(error)) throw error;
}
if (captured) {
const output = path.join(tempDir, "checkpoint");
const outbound: SandboxSyncOperation[] = [{ operationId: nextSyncOperationId(), files: [
{ sourcePath: remoteOutput, targetPath: output, kind: "directory" },
] }];
assertSyncOperationsConfined(outbound, { sourceRoots: [runtimeRootDir], targetRoots: [tempDir] });
await withWorkspaceRestoreStep("workspace_transfer", () => input.client.syncOut!(outbound));
checkpointSnapshot = readCheckpointSnapshot(path.join(output, "manifest.sqlite"), checkpointBaseline);
await fs.rename(path.join(output, "payload"), extractedDir);
const metrics = await validateCheckpointPayload({ baseline: checkpointBaseline, snapshot: checkpointSnapshot, payload: extractedDir });
await input.onWorkspaceCheckpoint?.(metrics).catch(() => undefined);
}
} catch (error) {
if (error instanceof Error && error.message.includes("Unsafe workspace archive symlink")) Object.assign(error, { code: "WORKSPACE_RESTORE_UNSAFE_ARCHIVE" });
throw error;
} finally { await input.client.remove(remoteRoot).catch(() => undefined); }
} finally { if (checkpointBaseline !== baselineSnapshot) await disposeDirectorySnapshot(checkpointBaseline); }
} else if (nativeSyncOut) {
}
if (input.workspaceCheckpoint && !checkpointSnapshot) await input.onWorkspaceCheckpoint?.({ mode: "full", scannedEntries: null, changedFiles: null, payloadBytes: null }).catch(() => undefined);
if (!checkpointSnapshot && nativeSyncOut) {
// Native outbound: the provider materializes the sandbox workspace into
// a fresh host directory. It is a clean destroy-then-replace into a
// temp dir the orchestrator just created, so it maps exactly to a
@@ -2078,7 +2125,7 @@ export async function prepareSandboxManagedRuntime(input: {
const syncResult = await withWorkspaceRestoreStep("workspace_transfer", () => input.client.syncOut!(operations));
const transferredBytes = sumSyncResultBytes(syncResult);
await workspaceRestore.finish(transferredBytes, transferredBytes);
} else {
} else if (!checkpointSnapshot) {
const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-download.tar");
await withWorkspaceRestoreStep("workspace_transfer", () => input.client.run(
`sh -c ${shellQuote(createRemoteTarballFromDirectoryCommand({
@@ -2108,6 +2155,7 @@ export async function prepareSandboxManagedRuntime(input: {
});
});
}
if (!checkpointSnapshot) await confineFullWorkspaceExport(extractedDir);
const gitHeadToIntegrate = importedHead;
await withWorkspaceRestoreStep("directory_merge", async () => {
const mergeBaseline = repositories.length === 0 ? baselineSnapshot! : await selectDirectorySnapshot(baselineSnapshot!, {
@@ -276,3 +276,123 @@ it("uses sparse native transfer end-to-end, including replay and unsupported-run
expect(await fs.readdir(cache)).toEqual(saturated);
await recovered.cleanupWorkspaceSnapshot();
}, 30000);
async function exportFailureFixture(failure: "low_disk" | "full_root" | "full_special" | "digest" | "path" | "symlink") {
const { prepareSandboxManagedRuntime } = await import("./sandbox-managed-runtime.js");
const { DatabaseSync } = await import("node:sqlite");
const f = await fixture();
await disposeDirectorySnapshot(f.baseline);
const remote = path.join(f.temp, "remote"), outside = path.join(f.temp, "outside");
await fs.writeFile(outside, "private");
const metrics: unknown[] = [];
const counts = { captures: 0, fullExports: 0, capacityFailures: 0 };
const run = async (command: string) => {
const capture = command.includes("checkpoint.mjs");
if (capture) counts.captures++;
try { await execute("sh", ["-c", command]); }
catch (error) {
if (capture && ["low_disk", "full_root", "full_special"].includes(failure)) {
expect(String(error)).toContain("Workspace checkpoint storage is below its free-space reserve");
counts.capacityFailures++;
await expect(fs.stat(path.join(f.root, "new"))).rejects.toMatchObject({ code: "ENOENT" });
}
throw error;
}
};
const sync = async (operations: import("./sandbox-managed-runtime.js").SandboxSyncOperation[]) => {
for (const operation of operations) {
for (const file of operation.files) {
if (file.sourcePath === remote) {
counts.fullExports++;
if (failure === "full_root") {
await fs.rm(file.targetPath, { recursive: true, force: true });
await fs.symlink(remote, file.targetPath);
continue;
}
}
await fs.mkdir(path.dirname(file.targetPath), { recursive: true });
await fs.cp(file.sourcePath, file.targetPath, { recursive: true, verbatimSymlinks: true,
filter: source => !file.exclude?.some(exclude => path.relative(file.sourcePath, source) === exclude) });
if (file.sourcePath === remote && failure === "full_special") await execute("mkfifo", [path.join(file.targetPath, "unsafe-pipe")]);
if (["low_disk", "full_root", "full_special"].includes(failure) && file.targetPath.endsWith("/checkpoint.mjs")) {
const script = await fs.readFile(file.targetPath, "utf8");
// Execute the real capture helper against a provider filesystem whose
// reported remaining space is below the optional SQLite reserve.
await fs.writeFile(file.targetPath, script.replace("const __name", "fs.statfs = async () => ({ bavail: 128n * 1024n, bsize: 1024n });\nconst __name"));
}
if (file.sourcePath.endsWith("/output")) {
if (failure === "digest") await fs.writeFile(path.join(file.targetPath, "payload", "new"), "forged");
else if (failure === "path" || failure === "symlink") {
const manifest = new DatabaseSync(path.join(file.targetPath, "manifest.sqlite"));
try {
manifest.prepare("INSERT INTO records VALUES('baseline',?,?)").run(
failure === "path" ? "../escape" : "escape",
JSON.stringify({ kind: "symlink", target: outside }),
);
} finally { manifest.close(); }
}
}
}
for (const command of operation.postUploadCommands ?? []) await run(command.command);
}
return { operations: operations.map(op => ({ operationId: op.operationId, filesTransferred: 0, bytesTransferred: 0 })) };
};
const client = { makeDir: (dir: string) => fs.mkdir(dir, { recursive: true }).then(() => {}),
writeFile: (file: string, data: ArrayBuffer) => fs.writeFile(file, Buffer.from(data)), readFile: (file: string) => fs.readFile(file),
listFiles: (dir: string) => fs.readdir(dir), remove: (file: string) => fs.rm(file, { force: true, recursive: true }), run,
syncIn: sync, syncOut: sync };
const prepare = (overrides: Partial<Parameters<typeof prepareSandboxManagedRuntime>[0]> = {}) => prepareSandboxManagedRuntime({
spec: { provider: "test", sandboxId: "test", remoteCwd: remote, timeoutMs: 30000, apiKey: null, transport: "sandbox" },
adapterKey: "test", client, workspaceLocalDir: f.root, workspaceCheckpoint: true,
onWorkspaceCheckpoint: async metric => { metrics.push(metric); }, ...overrides,
});
const prepared = await prepare();
await fs.writeFile(path.join(remote, "new"), "tiny export");
await fs.unlink(path.join(remote, "deleted"));
return { ...f, remote, outside, prepared, prepare, metrics, counts };
}
it.each([false, true])("falls back once to a full tiny export below the sparse disk reserve (recovery=%s)", async recovery => {
const f = await exportFailureFixture("low_disk");
const snapshot = f.prepared.workspaceSyncSnapshot!;
const runtime = recovery ? await f.prepare({ workspaceInboundMode: "adopt_remote", workspaceBaseline: snapshot.baseline,
workspaceGitSnapshot: snapshot.gitSnapshot, workspaceRepositories: snapshot.repositories }) : f.prepared;
await fs.writeFile(path.join(f.root, "host-only"), "concurrent host edit");
await fs.symlink(f.outside, path.join(f.remote, "unsafe-link"));
await fs.symlink("../outside", path.join(f.remote, "escaping-link"));
await fs.symlink("new", path.join(f.remote, "safe-link"));
await runtime.restoreWorkspace();
expect(f.counts).toEqual({ captures: 1, fullExports: 1, capacityFailures: 1 });
expect(f.metrics).toEqual([{ mode: "full", scannedEntries: null, changedFiles: null, payloadBytes: null }]);
expect(await fs.readFile(path.join(f.root, "new"), "utf8")).toBe("tiny export");
expect(await fs.readFile(path.join(f.root, "host-only"), "utf8")).toBe("concurrent host edit");
await expect(fs.stat(path.join(f.root, "deleted"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(f.outside, "utf8")).toBe("private");
for (const name of ["unsafe-link", "escaping-link"]) {
await expect(fs.lstat(path.join(f.root, name))).rejects.toMatchObject({ code: "ENOENT" });
}
expect(await fs.readlink(path.join(f.root, "safe-link"))).toBe("new");
await runtime.cleanupWorkspaceSnapshot();
if (recovery) await f.prepared.cleanupWorkspaceSnapshot();
}, 30000);
it.each(["digest", "path", "symlink"] as const)("never falls back after returned sparse %s validation fails", async attack => {
const f = await exportFailureFixture(attack);
await expect(f.prepared.restoreWorkspace()).rejects.toThrow();
expect(f.counts.fullExports).toBe(0);
expect(f.metrics).toEqual([]);
await expect(fs.stat(path.join(f.root, "new"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(f.root, "deleted"), "utf8")).toBe("delete me");
expect(await fs.readFile(f.outside, "utf8")).toBe("private");
await f.prepared.cleanupWorkspaceSnapshot();
}, 30000);
it.each(["full_root", "full_special"] as const)("rejects unsafe %s data before host writes after capacity fallback", async attack => {
const f = await exportFailureFixture(attack);
await expect(f.prepared.restoreWorkspace()).rejects.toThrow("Unsafe workspace export directory");
expect(f.counts).toEqual({ captures: 1, fullExports: 1, capacityFailures: 1 });
await expect(fs.stat(path.join(f.root, "new"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await fs.readFile(path.join(f.root, "deleted"), "utf8")).toBe("delete me");
expect(await fs.readFile(f.outside, "utf8")).toBe("private");
await f.prepared.cleanupWorkspaceSnapshot();
}, 30000);
@@ -7,7 +7,7 @@ export const executionWorkspaceRepositoryAccess = pgTable("execution_workspace_r
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
rootKey: text("root_key").notNull(),
canonicalCwd: text("canonical_cwd").notNull(),
kind: text("kind").$type<"source" | "reader" | "project_source" | "repository">().notNull(),
kind: text("kind").$type<"source" | "reader" | "project_source" | "shared_project_source" | "repository">().notNull(),
scopeId: uuid("scope_id").notNull(),
issueId: uuid("issue_id"),
agentId: uuid("agent_id"),
@@ -3,7 +3,7 @@ import { mkdtemp } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns } from "@paperclipai/db";
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces, principalPermissionGrants, companyMemberships } from "@paperclipai/db";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
@@ -33,6 +33,30 @@ const support = await getEmbeddedPostgresTestSupport();
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({ id: f.runId, status: "running" });
});
it("keeps projectless protected-folder aliases readable while enforcing scoped write grants", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const [project] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Protected physical source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await server.db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected folder", cwd: f.workspace });
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
name: "Projectless alias", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory" }).returning();
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
const selection = { selection: { kind: "existing", workspaceId: workspace.id }, expectedBindingRevision: 0, requestKey: "protected-alias" };
const repository = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "protected-root-write" };
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: workspace.id, projectId: null } });
await expect(call("select_workspace", selection)).rejects.toThrow(/protected|not authorized/);
await expect(call("prepare_repository", repository)).rejects.toThrow(/protected|not authorized/);
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId,
membershipRole: "member", status: "active" });
await server.db.insert(principalPermissionGrants).values([
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
{ companyId: f.companyId, principalType: "user", principalId: f.userId!, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
]);
await expect(call("select_workspace", selection)).resolves.toMatchObject({ kind: "scheduled" });
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
});
it("does not expose repository preparation or workspace selection to Ask mode", async () => {
const f = await server.fixture({ mode: "ask", conversation: true, disableWakeOnDemand: true });
await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "denied" } })).rejects.toThrow();
@@ -88,7 +112,7 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
await server.db.update(agents).set({ permissions: policy([f.projectId]) }).where(eq(agents.id, f.agentId));
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: source.id, projectId: f.projectId } });
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).rejects.toThrow(/outside.*boundary/i);
// Project permission cannot expose the unrelated task's files through a
// shared physical root. Give that fixture its own folder before testing
// the permitted source-only preparation path.
@@ -96,6 +120,7 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
const unrelatedCwd = await mkdtemp(join(dirname(f.workspace), "unrelated-workspace-"));
await server.db.update(executionWorkspaces).set({ cwd: unrelatedCwd }).where(eq(executionWorkspaces.id, unrelated.id));
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.projectId).toBeNull();
});
@@ -89,7 +89,7 @@ const support = await getEmbeddedPostgresTestSupport();
{ kind: "source", scopeId: f.workspaceId, cwd: await fs.realpath(oldCwd) },
{ kind: "project_source", scopeId: project.id, cwd: await fs.realpath(oldCwd) },
]));
expect(retained.every(entry => ["source", "project_source"].includes(entry.kind))).toBe(true);
expect(retained.every(entry => ["source", "project_source", "shared_project_source"].includes(entry.kind))).toBe(true);
expect(await fs.readFile(path.join(oldCwd, "private.txt"), "utf8")).toBe("Retained old files");
expect(await service.update(f.workspaceId, { metadata: null })).toMatchObject({
metadata: { _issuePrivacySources: { [ancestor.id]: true, [f.issueId]: true } },
@@ -8,7 +8,7 @@ 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, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces,
heartbeatRuns, issueAccessGrants, issues, projects, projectWorkspaces, projectAccessMembers, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
heartbeatRuns, issueAccessGrants, issues, projects, projectWorkspaces, projectAccessMembers, principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { assertExecutionWorkspaceRootReadAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
@@ -228,6 +228,94 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await readFile(path.join(checkout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
});
it("requires the admitted actor's write grant for a projectless alias of a protected configured root", async () => {
const f = await fixture();
await db.update(companyMemberships).set({ membershipRole: "member" }).where(eq(companyMemberships.principalId, f.userId));
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected shared root",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const alias = path.join(root, `${f.issueId}-protected-alias`);
await symlink(f.admission.cwd, alias);
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected source", cwd: alias }).returning();
const read = () => assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
sourceWorkspaceId: f.workspace.id, actor: f.actor });
await expect(read()).resolves.toBeUndefined();
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
await expect(f.request({ kind: "url", url: "https://github.com/public/writable" })).rejects.toThrow(/protected|not authorized/);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId,
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } });
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId,
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } }).returning();
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, source.id));
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
await expect(read()).resolves.toBeUndefined();
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
});
it("keeps genuine isolated Git copies read-only against their protected source project", async () => {
const f = await fixture();
const sourceRoot = path.join(root, `${f.issueId}-isolated-source`);
await mkdir(sourceRoot);
const exec = promisify(execFileCallback);
await exec("git", ["init", "--initial-branch=main", sourceRoot]);
await writeFile(path.join(sourceRoot, "README.md"), "Protected source\n");
await exec("git", ["-C", sourceRoot, "add", "."]);
await exec("git", ["-C", sourceRoot, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
await exec("git", ["-C", sourceRoot, "worktree", "add", "-b", "isolated-task", f.admission.cwd]);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected copy source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Original", cwd: sourceRoot }).returning();
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id, strategyType: "git_worktree" }).where(eq(executionWorkspaces.id, f.workspace.id));
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
// Physical configured ownership overrides a misleading isolation label.
await db.update(projectWorkspaces).set({ cwd: f.admission.cwd }).where(eq(projectWorkspaces.id, source.id));
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
});
it.each(["move", "delete"] as const)("retains protected shared execution-source write scope after %s", async change => {
const f = await fixture(), source = await standardPeer(f);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected execution owner",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, source.workspace.id));
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
const grants = await db.insert(principalPermissionGrants).values([
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
{ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
]).returning();
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
if (change === "move") {
const moved = path.join(root, `${source.issueId}-shared-moved`);
await mkdir(moved);
await executionWorkspaceService(db).update(source.workspace.id, { cwd: moved, projectId: null });
} else await db.delete(executionWorkspaces).where(eq(executionWorkspaces.id, source.workspace.id));
for (const grant of grants) await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized|workspace access/);
});
it("does not require historical readers to retain write grants for another authorized writer", async () => {
const f = await fixture(), peer = await standardPeer(f);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Shared source writers",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Shared", cwd: f.admission.cwd });
const prior = await db.insert(principalPermissionGrants).values([
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
{ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
]).returning();
await f.service.prepareForAdmission(f.admission);
await db.insert(principalPermissionGrants).values([
{ companyId: f.companyId, principalType: "agent", principalId: peer.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: peer.agentId } },
{ companyId: f.companyId, principalType: "user", principalId: peer.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: peer.agentId } },
]);
for (const grant of prior) await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
await expect(f.service.guardForPublication(peer.admission)).resolves.toBeUndefined();
});
it("checks configured private aliases against both admitted agent and responsible user before capture and publication", async () => {
const f = await fixture();
const alias = path.join(root, `${f.issueId}-configured-alias`);
@@ -172,29 +172,44 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
// Retain the privacy owner, not its disposable workspace configuration.
// Execution sources may later lose their project FK; retained files must not.
const sourceProjectIds = new Set(entries.filter(entry => entry.kind === "project_source").map(entry => entry.scopeId));
const sharedProjectIds = new Set(entries.filter(entry => entry.kind === "shared_project_source").map(entry => entry.scopeId));
const isolatedProjectIds = new Set<string>();
if (sourceIds.size) {
const owners = await tx.select({ projectId: executionWorkspaces.projectId }).from(executionWorkspaces).where(and(
const owners = await tx.select({ projectId: executionWorkspaces.projectId, strategyType: executionWorkspaces.strategyType }).from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, input.companyId), or(...[...sourceIds].map(id => eq(executionWorkspaces.id, id)))));
for (const owner of owners) if (owner.projectId) sourceProjectIds.add(owner.projectId);
for (const owner of owners) if (owner.projectId) {
sourceProjectIds.add(owner.projectId);
if (owner.strategyType === "git_worktree") isolatedProjectIds.add(owner.projectId);
else sharedProjectIds.add(owner.projectId);
}
}
for (const source of configured) {
if (!source.cwd || sourceProjectIds.has(source.projectId)) continue;
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 (sourceRoot !== canonicalCwd) continue;
sourceProjectIds.add(source.projectId);
sharedProjectIds.add(source.projectId);
}
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds };
// Older retained owners have no sharing classification. Require writes
// unless an authoritative retained workspace proves an isolated copy.
for (const entry of entries) if (entry.kind === "project_source" && !isolatedProjectIds.has(entry.scopeId)) {
sharedProjectIds.add(entry.scopeId);
}
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds };
}
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>) {
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>,
write?: { sharedProjectIds: Set<string> } & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">) {
for (const projectId of projectIds) {
const [project] = await db.select({ id: projects.id }).from(projects).where(and(
eq(projects.id, projectId), eq(projects.companyId, companyId), await projectReadSqlCondition(db, actor)));
if (!project) throw forbidden("Workspace source project is no longer accessible");
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId);
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId, {
...write, write: write?.sharedProjectIds.has(projectId),
});
}
}
@@ -216,10 +231,10 @@ export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction
});
if (!directory) return;
if (!directory.isDirectory()) throw forbidden("Workspace files cannot be detached from unverifiable provenance");
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds } = await resolveRepositoryRootSources(tx, {
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(tx, {
companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: input.workspaceId, inspectCurrentAliases: true,
});
for (const [kind, ids] of [["source", sourceIds], ["project_source", sourceProjectIds]] as const) {
for (const [kind, ids] of [["source", sourceIds], ["project_source", sourceProjectIds], ["shared_project_source", sharedProjectIds]] as const) {
for (const scopeId of ids) {
if (!entries.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, canonicalCwd, rootKey, kind, scopeId });
@@ -237,6 +252,15 @@ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransact
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds);
}
/** Selection validates writable source scope without registering a reader or changing policy. */
export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransaction, input: {
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId: string;
} & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">): Promise<void> {
const { sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true });
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId);
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds, { ...input, sharedProjectIds });
}
/** Workspace aggregate child. It owns acquisition receipts, never task binding or provider sessions. */
export function executionWorkspaceRepositoryService(db: Db) {
async function catalog(companyId: string, userId: string | null, localTrusted: boolean) {
@@ -308,9 +332,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
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;
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; issueId?: string; preserveOwnAdmission?: boolean;
}) {
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds } = await resolveRepositoryRootSources(tx, input);
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(tx, input);
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)));
@@ -333,7 +357,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
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 };
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds);
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds,
input.reader?.runId === reader.scopeId ? { sharedProjectIds, issueId: issue.id } : undefined);
if (ownAdmissionOnly) {
const [ownSource] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.sourceWorkspaceId!),
@@ -356,7 +381,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
}
if (input.actor) {
await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds);
if (!input.issueId) throw forbidden("Workspace write authorization requires a task");
await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds, { sharedProjectIds, issueId: input.issueId });
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, sourceId);
}
for (const sourceId of sourceIds) {
@@ -369,6 +395,11 @@ export function executionWorkspaceRepositoryService(db: Db) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "project_source", scopeId: sourceId });
}
}
for (const sourceId of sharedProjectIds) {
if (!entries.some(entry => entry.kind === "shared_project_source" && entry.scopeId === sourceId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "shared_project_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);
}
@@ -427,7 +458,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
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 });
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor, issueId: input.issueId });
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);
+5 -1
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 } from "./execution-workspace-repositories.js";
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess } 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";
@@ -1412,6 +1412,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
));
if (!workspace) throw notFound("Workspace is unavailable or inaccessible");
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId });
if (workspace.cwd) await assertExecutionWorkspaceRootWriteAccess(reader, {
companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor,
issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId,
});
return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId,
executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } };
}