mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Preserve legacy folder privacy and isolate unusable alternatives
Retain old physical owners before workspace source mutation, keep privacy history under the shared root fence, and isolate automatic file-availability failures. Skip unrelated invalid paths while retaining strict selected-root checks. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2503d8dda4
commit
a42b2852fb
7 files changed
+280
-38
No files matched your search
+1
-1
@@ -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 and configured workspace 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. 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).
|
||||
@@ -316,6 +316,56 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
expect(resolved.capabilities.preview).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["missing", "denied", "symlink loop", "overlong path"])("skips an unused %s configured alternative while keeping explicit reads strict", async alternative => {
|
||||
const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
|
||||
try {
|
||||
const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot });
|
||||
// Current task files have their own source; the task's organizational
|
||||
// project still contributes a configured alternative to auto lookup.
|
||||
await db.update(executionWorkspaces).set({ projectId: null, projectWorkspaceId: null, providerType: "local_fs" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId));
|
||||
await fs.writeFile(path.join(executionRoot, "report.txt"), "CURRENT_TASK_REPORT");
|
||||
if (alternative === "missing" || alternative === "symlink loop") {
|
||||
await fs.rm(projectRoot, { recursive: true });
|
||||
if (alternative === "symlink loop") await fs.symlink(projectRoot, projectRoot);
|
||||
} else if (alternative === "overlong path") {
|
||||
await db.update(projectWorkspaces).set({ cwd: path.join(projectRoot, "x".repeat(512)) }).where(eq(projectWorkspaces.id, graph.projectWorkspaceId));
|
||||
} else {
|
||||
const [privateIssue] = await db.insert(issues).values({ companyId: graph.companyId, title: "Private alternative source", visibility: "private", responsibleUserId: "private-owner" }).returning();
|
||||
await db.insert(executionWorkspaces).values({ companyId: graph.companyId, sourceIssueId: privateIssue.id, name: "Private alternative", cwd: projectRoot,
|
||||
mode: "shared_workspace", strategyType: "task_directory", providerType: "local_fs" });
|
||||
await fs.writeFile(path.join(projectRoot, "report.txt"), "PRIVATE_ALTERNATIVE_BYTES");
|
||||
}
|
||||
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
|
||||
const base = `/api/issues/${graph.issueId}/file-resources`;
|
||||
const listed = await request(app).get(`${base}/list`).expect(200);
|
||||
expect(listed.body.items).toEqual(expect.arrayContaining([expect.objectContaining({ relativePath: "report.txt" })]));
|
||||
await request(app).get(`${base}/resolve`).query({ path: "report.txt" }).expect(200);
|
||||
const preview = await request(app).get(`${base}/content`).query({ path: "report.txt" }).expect(200);
|
||||
expect(preview.body.content.data).toBe("CURRENT_TASK_REPORT");
|
||||
const download = await request(app).get(`${base}/content`).query({ path: "report.txt", download: true }).expect(200);
|
||||
expect(download.text).toBe("CURRENT_TASK_REPORT");
|
||||
const explicit = { projectId: graph.projectId, workspaceId: graph.projectWorkspaceId };
|
||||
for (const target of [explicit, { workspace: "project" }]) {
|
||||
await request(app).get(`${base}/list`).query(target).expect(404);
|
||||
await request(app).get(`${base}/resolve`).query({ ...target, path: "report.txt" }).expect(404);
|
||||
await request(app).get(`${base}/content`).query({ ...target, path: "report.txt" }).expect(404);
|
||||
await request(app).get(`${base}/content`).query({ ...target, path: "report.txt", download: true }).expect(404);
|
||||
}
|
||||
const batch = await request(app).post(`${base}/availability`).send({ queries: [
|
||||
{ path: "report.txt" }, { workspace: "execution", path: "report.txt" },
|
||||
{ workspace: "project", path: "report.txt" }, { ...explicit, path: "report.txt" },
|
||||
] }).expect(200);
|
||||
expect(batch.body.results).toEqual([
|
||||
expect.objectContaining({ openable: true, resource: expect.objectContaining({ workspaceKind: "execution_workspace" }) }),
|
||||
expect.objectContaining({ openable: true, resource: expect.objectContaining({ workspaceKind: "execution_workspace" }) }),
|
||||
expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null }),
|
||||
expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null }),
|
||||
]);
|
||||
expect(JSON.stringify(batch.body)).not.toContain(projectRoot);
|
||||
expect(JSON.stringify(batch.body)).not.toContain("PRIVATE_ALTERNATIVE_BYTES");
|
||||
} finally { await fs.rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("reads projectless task files before and after organizational project assignment", async () => {
|
||||
const { projectRoot, executionRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, executionRoot });
|
||||
@@ -357,7 +407,10 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
const deny = async () => {
|
||||
for (const pending of reads()) {
|
||||
const response = await pending;
|
||||
expect(response.status).toBe(404);
|
||||
if (response.body.kind === "workspace_file_availability") {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.results).toEqual([expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null })]);
|
||||
} else expect(response.status).toBe(404);
|
||||
for (const hidden of ["PRIVATE_ROOT_BYTES", source.id, privateIssueId, executionRoot, "Private source workspace"]) expect(JSON.stringify(response.body)).not.toContain(hidden);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ import request from "supertest";
|
||||
import { taskWorkspaceRoutes } from "../routes/task-workspaces.js";
|
||||
import { issueRoutes } from "../routes/issues.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { agents, authUsers, companies, companyMemberships, completionContracts, createDb, environmentLeases, executionWorkspaceRepositories, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, workspaceOperations, principalPermissionGrants, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { agents, authUsers, companies, companyMemberships, completionContracts, createDb, environmentLeases, executionWorkspaceRepositories, executionWorkspaceRepositoryAccess, executionWorkspaces, heartbeatRuns, issues, nativeRunFinalizations, nativeRunResults, workspaceOperations, principalPermissionGrants, projects, projectWorkspaces } from "@paperclipai/db";
|
||||
import { executionWorkspaceService } from "../services/execution-workspaces.js";
|
||||
import { authorizationService, canActorReadExecutionWorkspace } from "../services/authorization.js";
|
||||
import { assertChatExecutionDefaultsAccess } from "../services/chat-execution-defaults.js";
|
||||
@@ -57,6 +57,68 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(overview.items).toEqual(expect.arrayContaining([expect.objectContaining({ workspaceId: f.workspaceId, projectId: null })]));
|
||||
});
|
||||
|
||||
it.each(["replace", "clear"] as const)("retains unbound task privacy and old root ownership when an update %ss its source", async action => {
|
||||
const f = await fixture(), service = executionWorkspaceService(db);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-update-provenance-"));
|
||||
try {
|
||||
const oldCwd = path.join(root, "old"), newCwd = path.join(root, "new");
|
||||
await fs.mkdir(oldCwd);
|
||||
await fs.mkdir(newCwd);
|
||||
await fs.writeFile(path.join(oldCwd, "private.txt"), "Retained old files");
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Original source policy" }).returning();
|
||||
const [ancestor, replacement] = await db.insert(issues).values([
|
||||
{ companyId: f.companyId, title: "Earlier private source" },
|
||||
{ companyId: f.companyId, title: "Replacement task" },
|
||||
]).returning();
|
||||
// This legacy source has never been bound through bindTaskWorkspace.
|
||||
await db.update(executionWorkspaces).set({ sourceIssueId: ancestor.id })
|
||||
.where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
await db.update(executionWorkspaces).set({ cwd: oldCwd, projectId: project.id, sourceIssueId: f.issueId })
|
||||
.where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
const changed = await service.update(f.workspaceId, { cwd: newCwd, projectId: null,
|
||||
sourceIssueId: action === "replace" ? replacement.id : null,
|
||||
metadata: { label: "replacement metadata", _issuePrivacySources: {} },
|
||||
});
|
||||
expect(changed).toMatchObject({ cwd: newCwd, projectId: null, metadata: {
|
||||
label: "replacement metadata", _issuePrivacySources: { [ancestor.id]: true, [f.issueId]: true },
|
||||
} });
|
||||
const retained = await db.select().from(executionWorkspaceRepositoryAccess)
|
||||
.where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
expect(retained.map(entry => ({ kind: entry.kind, scopeId: entry.scopeId, cwd: entry.canonicalCwd })))
|
||||
.toEqual(expect.arrayContaining([
|
||||
{ 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(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 } },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back a source update when existing root provenance cannot be verified", async () => {
|
||||
const f = await fixture(), service = executionWorkspaceService(db);
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-update-invalid-root-"));
|
||||
try {
|
||||
const oldCwd = path.join(root, "not-a-directory");
|
||||
await fs.writeFile(oldCwd, "Unverifiable former root");
|
||||
await db.update(executionWorkspaces).set({ cwd: oldCwd, metadata: { label: "original" } })
|
||||
.where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
const [before] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
await expect(service.update(f.workspaceId, { cwd: path.join(root, "new"), sourceIssueId: null, metadata: null }))
|
||||
.rejects.toThrow("Workspace files cannot be detached from unverifiable provenance");
|
||||
const [after] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
expect(after).toEqual(before);
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess)
|
||||
.where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual([]);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["explicit", "channel"] as const)("persists the canonical null binding for a %s task-directory selection", async (source) => {
|
||||
const f = await fixture(), settings = instanceSettingsService(db), previous = await settings.getExperimental();
|
||||
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
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";
|
||||
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
|
||||
import { buildGitAuthInvocation } from "../services/git-credentials.js";
|
||||
@@ -123,7 +124,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
beforePublish: async cloneCwd => { await beforePublish?.(); await input.beforePublish(cloneCwd); } }));
|
||||
}
|
||||
|
||||
it.each(["empty", "different_identity", "unreadable_alias", "anonymous_private_edits", "authorized_peer"] as const)(
|
||||
it.each(["empty", "different_identity", "unreadable_alias", "loop_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,
|
||||
@@ -133,15 +134,15 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
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") {
|
||||
if (scenario === "unreadable_alias" || scenario === "loop_alias") {
|
||||
const alias = path.join(root, `${f.issueId}-private-alias`);
|
||||
await symlink(f.admission.cwd, alias);
|
||||
await symlink(scenario === "loop_alias" ? alias : 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" });
|
||||
if (scenario === "unreadable_alias" && String(pathname) === alias) throw Object.assign(new Error("Private alias unavailable"), { code: "EACCES" });
|
||||
return actual(pathname);
|
||||
});
|
||||
}
|
||||
@@ -184,6 +185,31 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId))).toEqual(beforeRead);
|
||||
});
|
||||
|
||||
it("preserves legacy private publishers before moving their workspace away from shared files", async () => {
|
||||
const f = await fixture(), peer = await standardPeer(f);
|
||||
const repository = { kind: "url" as const, url: "https://github.com/public/legacy-shared" };
|
||||
await f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
|
||||
request: { repository, requestKey: randomUUID() } });
|
||||
await privateTask(f);
|
||||
await f.request(repository);
|
||||
await localPrivateClone(f);
|
||||
const [checkout] = await f.service.prepareForAdmission(f.admission);
|
||||
expect(await readFile(path.join(checkout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
// Both receipts/files predate provenance registration. The move itself
|
||||
// must preserve the old owner before its available cwd becomes unrelated.
|
||||
await db.delete(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
const moved = path.join(root, `${f.issueId}-legacy-moved`);
|
||||
await mkdir(moved);
|
||||
await executionWorkspaceService(db).update(f.workspace.id, { cwd: moved });
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: peer.admission.cwd,
|
||||
sourceWorkspaceId: peer.workspace.id, actor: peer.actor })).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
const retained = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
|
||||
expect(retained.some(entry => entry.kind === "source" && entry.scopeId === f.workspace.id)).toBe(true);
|
||||
expect(retained.some(entry => entry.kind === "repository" || entry.kind === "reader")).toBe(false);
|
||||
expect(await readFile(path.join(checkout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
|
||||
});
|
||||
|
||||
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`);
|
||||
@@ -240,6 +266,37 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project/);
|
||||
});
|
||||
|
||||
it.each(["configured", "execution"] as const)("ignores an unrelated %s workspace symlink loop", async kind => {
|
||||
const f = await fixture();
|
||||
const loop = path.join(root, `${f.issueId}-unrelated-loop`);
|
||||
await symlink(loop, loop);
|
||||
if (kind === "configured") {
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unavailable unrelated project", visibility: "private" }).returning();
|
||||
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Loop", cwd: loop });
|
||||
} else {
|
||||
await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Unrelated loop", cwd: loop, mode: "isolated_workspace", strategyType: "task_directory" });
|
||||
}
|
||||
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an unusable selected root without releasing a retained private source", async () => {
|
||||
const f = await fixture();
|
||||
await privateTask(f);
|
||||
await f.service.prepareForAdmission(f.admission);
|
||||
const peer = await standardPeer(f);
|
||||
const loop = path.join(root, `${f.issueId}-selected-loop`);
|
||||
await symlink(loop, loop);
|
||||
await db.update(executionWorkspaces).set({ cwd: loop }).where(eq(executionWorkspaces.id, f.workspace.id));
|
||||
await expect(f.service.prepareForAdmission({ ...f.admission, cwd: loop })).rejects.toThrow(/ELOOP/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: loop,
|
||||
sourceWorkspaceId: f.workspace.id, actor: f.actor })).rejects.toThrow(/ELOOP/);
|
||||
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: peer.admission.cwd,
|
||||
sourceWorkspaceId: peer.workspace.id, actor: peer.actor })).rejects.toThrow(/workspace access|authorization boundary/);
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
@@ -53,7 +53,7 @@ async function legacyRepositoryRootMayMatch(cwd: string, candidate: string): Pro
|
||||
return candidate === unresolved || candidate.startsWith(`${unresolved}${path.sep}`);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (!["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(code ?? "")) throw error;
|
||||
if (!["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(code ?? "")) throw error;
|
||||
if (code === "EACCES" || code === "EPERM") inaccessiblePrefix = probe;
|
||||
const parent = path.dirname(probe);
|
||||
if (parent === probe) return true;
|
||||
@@ -114,7 +114,7 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
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;
|
||||
if (["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
});
|
||||
if (peerRoot === canonicalCwd) sourceIds.add(workspace.id);
|
||||
@@ -149,7 +149,7 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
const published = publishedNames.has(name);
|
||||
if (!published && !inspectCurrentAliases) continue;
|
||||
const peerRoot = peer.cwd ? await fs.realpath(peer.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null;
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
}) : null;
|
||||
// A basename identifies a repository, not its publisher. Independent
|
||||
@@ -180,7 +180,7 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
|
||||
for (const source of configured) {
|
||||
if (!source.cwd || sourceProjectIds.has(source.projectId)) continue;
|
||||
const sourceRoot = await fs.realpath(source.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes(error.code ?? "")) return null;
|
||||
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
|
||||
throw error;
|
||||
});
|
||||
if (sourceRoot !== canonicalCwd) continue;
|
||||
@@ -198,6 +198,36 @@ async function assertRootProjectAccess(db: Db | DbTransaction, actor: Authorizat
|
||||
}
|
||||
}
|
||||
|
||||
export async function lockExecutionWorkspaceRootProvenance(tx: DbTransaction, companyId: string) {
|
||||
// Acquire before task/workspace row locks. Hold only for policy and receipt
|
||||
// transactions, never Git, provider execution, or snapshot capture.
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`task-repository-roots:${companyId}`}, 0))`);
|
||||
}
|
||||
|
||||
/** Preserve deny-only owners before a workspace can detach from existing files.
|
||||
* Caller holds the company root fence, followed by the workspace row lock. */
|
||||
export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction, input: {
|
||||
companyId: string; workspaceId: string; cwd: string | null;
|
||||
}): Promise<void> {
|
||||
if (!input.cwd) return;
|
||||
const directory = await fs.stat(input.cwd).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
if (!directory) return;
|
||||
if (!directory.isDirectory()) throw forbidden("Workspace files cannot be detached from unverifiable provenance");
|
||||
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds } = 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 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** File readers share admission's source closure, without changing root policy. */
|
||||
export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransaction, input: {
|
||||
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string;
|
||||
@@ -232,11 +262,6 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
if (trust.kind === "denied") throw forbidden(trust.detail);
|
||||
return trust.kind === "low_trust_review";
|
||||
}
|
||||
async function lockRootPolicies(tx: DbTransaction, companyId: string) {
|
||||
// Acquire before task/workspace row locks. Hold only for policy and receipt
|
||||
// transactions, never Git, provider execution, or snapshot capture.
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`task-repository-roots:${companyId}`}, 0))`);
|
||||
}
|
||||
async function reserveRootPolicy(tx: DbTransaction, companyId: string, cwd: string, mode: "anonymous_only" | "managed") {
|
||||
const canonicalCwd = await canonicalRepositoryRoot(cwd);
|
||||
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
|
||||
@@ -350,13 +375,13 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
}
|
||||
async function reserveRootAccess(input: Parameters<typeof authorizeRootAccess>[1]) {
|
||||
return db.transaction(async tx => {
|
||||
await lockRootPolicies(tx, input.companyId);
|
||||
await lockExecutionWorkspaceRootProvenance(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);
|
||||
await lockExecutionWorkspaceRootProvenance(tx, companyId);
|
||||
return reserveRootPolicy(tx, companyId, cwd, mode);
|
||||
});
|
||||
}
|
||||
@@ -382,7 +407,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
const available = await catalog(input.companyId, userId, localTrusted);
|
||||
const requestedRef = parsed.ref ?? "HEAD";
|
||||
const receipt = await db.transaction(async tx => {
|
||||
await lockRootPolicies(tx, input.companyId);
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId), await issueReadSqlCondition(tx, input.actor))).for("no key update");
|
||||
if (!issue) throw notFound("Task not found");
|
||||
const currentDecision = await authorizationService(tx).decide({ actor: input.actor, action: "issue:mutate", resource: issueResource(issue) });
|
||||
@@ -432,7 +457,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
|
||||
/** 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);
|
||||
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
|
||||
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId)));
|
||||
const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, input.workspaceId), eq(executionWorkspaces.companyId, input.companyId)));
|
||||
if (!issue || !workspace || issue.executionWorkspaceId !== workspace.id || !workspace.cwd
|
||||
|
||||
@@ -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 } from "./execution-workspace-repositories.js";
|
||||
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance } 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";
|
||||
@@ -3189,13 +3189,46 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
},
|
||||
|
||||
update: async (id: string, patch: Partial<typeof executionWorkspaces.$inferInsert>) => {
|
||||
const row = await db
|
||||
.update(executionWorkspaces)
|
||||
.set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(executionWorkspaces.id, id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
const sourceKeys = ["cwd", "projectId", "projectWorkspaceId", "sourceIssueId"] as const;
|
||||
if (patch.metadata === undefined && !sourceKeys.some(key => patch[key] !== undefined)) {
|
||||
const row = await db.update(executionWorkspaces).set({ ...patch, updatedAt: new Date() })
|
||||
.where(eq(executionWorkspaces.id, id)).returning().then(rows => rows[0] ?? null);
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
}
|
||||
const [identity] = await db.select({ companyId: executionWorkspaces.companyId }).from(executionWorkspaces)
|
||||
.where(eq(executionWorkspaces.id, id));
|
||||
if (!identity) return null;
|
||||
return db.transaction(async tx => {
|
||||
// Repository publication takes the company fence before task/workspace
|
||||
// row locks. Keep that order while retaining the old physical owner.
|
||||
await lockExecutionWorkspaceRootProvenance(tx, identity.companyId);
|
||||
const [current] = await tx.select().from(executionWorkspaces).where(and(
|
||||
eq(executionWorkspaces.id, id), eq(executionWorkspaces.companyId, identity.companyId),
|
||||
)).for("no key update");
|
||||
if (!current) return null;
|
||||
if (sourceKeys.some(key => patch[key] !== undefined && patch[key] !== current[key])) {
|
||||
await preserveExecutionWorkspaceRootProvenance(tx, {
|
||||
companyId: current.companyId, workspaceId: current.id, cwd: current.cwd,
|
||||
});
|
||||
}
|
||||
const nextPatch = { ...patch };
|
||||
const retainedSources = {
|
||||
...parseObject(parseObject(patch.metadata)._issuePrivacySources),
|
||||
...parseObject(current.metadata?._issuePrivacySources),
|
||||
};
|
||||
if (patch.sourceIssueId !== undefined && patch.sourceIssueId !== current.sourceIssueId && current.sourceIssueId) {
|
||||
retainedSources[current.sourceIssueId] = true;
|
||||
}
|
||||
if (Object.keys(retainedSources).length > 0) {
|
||||
nextPatch.metadata = {
|
||||
...(patch.metadata !== undefined ? patch.metadata ?? {} : current.metadata ?? {}),
|
||||
_issuePrivacySources: retainedSources,
|
||||
};
|
||||
}
|
||||
const [row] = await tx.update(executionWorkspaces).set({ ...nextPatch, updatedAt: new Date() })
|
||||
.where(and(eq(executionWorkspaces.id, id), eq(executionWorkspaces.companyId, current.companyId))).returning();
|
||||
return row ? toExecutionWorkspace(row) : null;
|
||||
});
|
||||
},
|
||||
|
||||
// Reopen one closed isolated execution workspace so an authorized issue can
|
||||
|
||||
@@ -1065,7 +1065,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
|
||||
candidate.rootPath = canonicalCwd;
|
||||
} catch (error) {
|
||||
if (isHttpStatus(error, 403) || isHttpStatus(error, 404)
|
||||
|| ["ENOENT", "ENOTDIR", "EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) {
|
||||
|| ["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes((error as NodeJS.ErrnoException).code ?? "")) {
|
||||
if (skipDeniedSources) continue;
|
||||
throw notFound("Workspace file not found");
|
||||
}
|
||||
@@ -1170,7 +1170,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
|
||||
}
|
||||
}
|
||||
|
||||
return visibleCandidates(candidates);
|
||||
return visibleCandidates(candidates, selector === "auto");
|
||||
}
|
||||
|
||||
async function loadAvailabilityTargets(
|
||||
@@ -1210,8 +1210,13 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
|
||||
error: unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" }),
|
||||
});
|
||||
} else {
|
||||
const [candidate] = await visibleCandidates([candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name })]);
|
||||
targets.set(targetKey, candidate ? { candidate } : { error: notFound("Project workspace not found") });
|
||||
try {
|
||||
const [candidate] = await visibleCandidates([candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name })]);
|
||||
targets.set(targetKey, candidate ? { candidate } : { error: notFound("Project workspace not found") });
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError) || !expectedAvailabilityReason(error)) throw error;
|
||||
targets.set(targetKey, { error });
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets;
|
||||
@@ -1291,7 +1296,17 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
|
||||
const untargetedQueries = queries.filter(
|
||||
(item) => !item.unavailableReason && !item.query.projectId && !item.query.workspaceId,
|
||||
);
|
||||
const initialCandidates = untargetedQueries.length > 0 ? await listCandidates(issue, "auto") : [];
|
||||
const candidatesBySelector = new Map<WorkspaceFileSelector, WorkspaceCandidate[]>();
|
||||
const selectorErrors = new Map<WorkspaceFileSelector, unknown>();
|
||||
for (const selector of new Set(untargetedQueries.map(item => item.query.workspace))) {
|
||||
try {
|
||||
candidatesBySelector.set(selector, await listCandidates(issue, selector));
|
||||
} catch (error) {
|
||||
if (!expectedAvailabilityReason(error)) throw error;
|
||||
selectorErrors.set(selector, error);
|
||||
}
|
||||
}
|
||||
const initialCandidates = candidatesBySelector.get("auto") ?? [];
|
||||
const needsDiscovery = initialCandidates.some((candidate) => !candidate.remote)
|
||||
&& untargetedQueries.some((item) => item.query.workspace === "auto");
|
||||
const discoveryCandidates = needsDiscovery
|
||||
@@ -1312,13 +1327,10 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor)
|
||||
: null;
|
||||
if (explicitTarget?.error) throw explicitTarget.error;
|
||||
|
||||
if (!explicitTarget && selectorErrors.has(item.query.workspace)) throw selectorErrors.get(item.query.workspace);
|
||||
const candidates = explicitTarget?.candidate
|
||||
? [explicitTarget.candidate]
|
||||
: initialCandidates.filter((candidate) => {
|
||||
if (item.query.workspace === "execution") return candidate.workspaceKind === "execution_workspace";
|
||||
if (item.query.workspace === "project") return candidate.workspaceKind === "project_workspace";
|
||||
return true;
|
||||
});
|
||||
: candidatesBySelector.get(item.query.workspace) ?? [];
|
||||
if (candidates.length === 0) {
|
||||
throw unprocessable("No workspace is available for this issue", { code: "no_workspace" });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user