Files
PaperClipAI/server/src/__tests__/task-workspace-binding.test.ts
T
DottaandPaperclip f329b24ee1 feat: give tasks durable project-optional workspaces (#15749)
Give tasks durable company-scoped workspace bindings independent of project assignment, channel defaults and authorized repository preparation. Reuse verified input seeds and sparse output recovery while preserving trust, privacy and controller provisioning boundaries.

Full CI and E2E, Superagent, legacy checks and Greptile Apex 5/5 passed on reviewed head 753680b61c. All review findings are resolved; the PR records focused verification and historical acceptance limits.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-12 20:45:18 -05:00

885 lines
73 KiB
TypeScript

import { findNativeChatWorkspaceScope } from "../services/native-runtime/native-chat-workspace.js";
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { assertGitSensitiveAdapterWorkspaceValid, createHeartbeatWorkspaceResolver } from "../services/heartbeat/workspaces.js";
import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import express from "express";
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, 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";
import { assertTaskWorkspaceAccess } from "../services/task-workspace-source-access.js";
import { deleteCompany } from "../services/company-deletion.js";
import { issueService } from "../services/issues.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("task-owned workspace bindings", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("paperclip-task-workspace-");
db = createDb(temporary.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => { await temporary?.cleanup(); });
async function fixture() {
const companyId = randomUUID(), issueId = randomUUID(), workspaceId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Task files", issuePrefix: `T${companyId.slice(0, 6)}` });
await db.insert(issues).values({ id: issueId, companyId, title: "Write a report" });
await db.insert(executionWorkspaces).values({ id: workspaceId, companyId, projectId: null, sourceIssueId: issueId,
mode: "shared_workspace", strategyType: "project_primary", name: "Task files", cwd: `/tmp/task-${issueId}` });
return { companyId, issueId, workspaceId, actor: { type: "board" as const, source: "local_implicit" as const, isInstanceAdmin: true } };
}
it("persists a projectless binding, preserves it on project edits, and lists it in overview", async () => {
const f = await fixture(), svc = executionWorkspaceService(db);
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const projectId = randomUUID();
await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Reports" });
const updated = await issueService(db).update(f.issueId, { projectId });
expect(updated?.executionWorkspaceId).toBe(f.workspaceId);
const view = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
expect(view.workspace?.projectId).toBeNull();
expect(view.workspace?.cwd).toBe(`/tmp/task-${f.issueId}`);
expect(view.bindingRevision).toBe(1);
const overview = await svc.listOverview(f.companyId, { limit: 20, offset: 0 });
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();
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", "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 } },
});
} 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 });
try {
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Legacy source" }).returning();
const [workspace] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id,
name: "Legacy folder", cwd: "/tmp/legacy-folder" }).returning();
const created = await issueService(db).create(f.companyId, {
title: "Use a new task folder", projectId: project.id,
projectWorkspaceId: workspace.id, executionWorkspaceId: f.workspaceId,
executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: "isolated_workspace" },
workspaceSelection: { kind: "task_directory" }, workspaceSelectionSource: source, workspaceSelectionActor: f.actor,
});
const [stored] = await db.select().from(issues).where(eq(issues.id, created.id));
expect(stored).toMatchObject({ projectId: project.id, projectWorkspaceId: null, executionWorkspaceId: null,
executionWorkspacePreference: null, executionWorkspaceSettings: { mode: "shared_workspace" },
workspaceSelection: { selection: { kind: "task_directory" }, source } });
expect((await executionWorkspaceService(db).inspectTaskWorkspace(f.companyId, created.id, f.actor)).workspace).toBeNull();
} finally {
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
}
});
it.each(["create", "createChild"] as const)("inherits a parent task folder through %s with isolated workspaces disabled", async (entryPoint) => {
const f = await fixture(), settings = instanceSettingsService(db), previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: false });
try {
await executionWorkspaceService(db).bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const tasks = issueService(db);
const input = { title: "Continue parent work", workspaceSelectionActor: f.actor,
workspaceSelection: { kind: "task_directory" as const }, workspaceSelectionSource: "channel" as const };
const child = entryPoint === "create"
? await tasks.create(f.companyId, { ...input, parentId: f.issueId })
: (await tasks.createChild(f.issueId, input)).issue;
expect(child).toMatchObject({ parentId: f.issueId, projectId: null, executionWorkspaceId: f.workspaceId,
executionWorkspacePreference: "reuse_existing" });
const view = await executionWorkspaceService(db).inspectTaskWorkspace(f.companyId, child.id, f.actor);
expect(view.workspace).toMatchObject({ id: f.workspaceId, cwd: `/tmp/task-${f.issueId}` });
const explicit = await tasks.create(f.companyId, { ...input, title: "Explicit separate folder", parentId: f.issueId,
workspaceSelectionSource: "explicit" });
expect(explicit.executionWorkspaceId).toBeNull();
} finally {
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
}
});
it.each([
{ entryPoint: "create", parentSource: "task_directory" },
{ entryPoint: "createChild", parentSource: "task_directory" },
{ entryPoint: "create", parentSource: "configured_source" },
{ entryPoint: "createChild", parentSource: "configured_source" },
] as const)("honors a child's explicit source through $entryPoint over its parent's $parentSource binding", async ({ entryPoint, parentSource }) => {
const f = await fixture(), tasks = issueService(db), workspaces = executionWorkspaceService(db);
const [sourceProject] = await db.insert(projects).values({ companyId: f.companyId, name: "Filesystem sources" }).returning();
const [parentConfigured, childConfigured] = await db.insert(projectWorkspaces).values([
{ companyId: f.companyId, projectId: sourceProject.id, name: "Parent files", cwd: `/tmp/parent-${f.issueId}` },
{ companyId: f.companyId, projectId: sourceProject.id, name: "Child files", cwd: `/tmp/child-${f.issueId}` },
]).returning();
if (parentSource === "configured_source") {
await db.update(executionWorkspaces).set({ projectId: sourceProject.id, projectWorkspaceId: parentConfigured.id })
.where(eq(executionWorkspaces.id, f.workspaceId));
}
await workspaces.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const parentBefore = await workspaces.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
const input = { title: "Use child-specific files", projectWorkspaceId: childConfigured.id, workspaceSelectionActor: f.actor };
const child = entryPoint === "create"
? await tasks.create(f.companyId, { ...input, parentId: f.issueId })
: (await tasks.createChild(f.issueId, input)).issue;
expect(child).toMatchObject({ parentId: f.issueId, projectId: null, projectWorkspaceId: childConfigured.id,
executionWorkspaceId: null, executionWorkspacePreference: null });
expect((await workspaces.inspectTaskWorkspace(f.companyId, child.id, f.actor)).workspace).toBeNull();
const parentAfter = await workspaces.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
expect(parentAfter.workspace).toEqual(parentBefore.workspace);
expect(parentAfter.bindingRevision).toBe(parentBefore.bindingRevision);
});
it.each(["create", "createChild", "strategy_only"] as const)("preserves an explicit null source through %s while applying organizational defaults", async entryPoint => {
const f = await fixture(), tasks = issueService(db), workspaces = executionWorkspaceService(db);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Organizational defaults" }).returning();
const [defaultSource, parentSource] = await db.insert(projectWorkspaces).values([
{ companyId: f.companyId, projectId: project.id, name: "Default files", cwd: `/tmp/default-${f.issueId}`, isPrimary: true },
{ companyId: f.companyId, projectId: project.id, name: "Parent files", cwd: `/tmp/parent-${f.issueId}` },
]).returning();
await db.update(issues).set({ projectId: project.id }).where(eq(issues.id, f.issueId));
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: parentSource.id })
.where(eq(executionWorkspaces.id, f.workspaceId));
await workspaces.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const input = { title: "Clear parent workspace choice", projectWorkspaceId: null, workspaceSelectionActor: f.actor };
const child = entryPoint === "create"
? await tasks.create(f.companyId, { ...input, parentId: f.issueId })
: (await tasks.createChild(f.issueId, { ...input,
...(entryPoint === "strategy_only" ? { executionWorkspaceInheritanceMode: "strategy_only" as const } : {}),
})).issue;
expect(child).toMatchObject({ projectId: project.id, projectWorkspaceId: defaultSource.id,
executionWorkspaceId: null, executionWorkspacePreference: null, workspaceSelection: null });
expect((await workspaces.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).workspace)
.toMatchObject({ id: f.workspaceId, projectWorkspaceId: parentSource.id });
});
it.each(["explicit", "channel", "inherited", "legacy"] as const)("keeps a reused task folder's null source authoritative for %s creation", async (source) => {
const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db);
const settings = instanceSettingsService(db), previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
try {
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Organization with unrelated source" }).returning();
const [projectWorkspace] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id,
name: "Unrelated repository", cwd: "/tmp/unrelated-source", isPrimary: true }).returning();
let parentId: string | undefined;
if (source === "inherited") {
const [parent] = await db.insert(issues).values({ companyId: f.companyId, projectId: project.id,
title: "Parent sharing task files", executionWorkspaceId: f.workspaceId }).returning();
parentId = parent.id;
}
const created = await tasks.create(f.companyId, {
title: "Reuse files under an organizational project", projectId: project.id, parentId,
workspaceSelectionActor: f.actor,
...(source === "legacy"
? { executionWorkspaceId: f.workspaceId, projectWorkspaceId: projectWorkspace.id, executionWorkspacePreference: "reuse_existing" }
: source === "inherited" ? {}
: { workspaceSelection: { kind: "existing" as const, workspaceId: f.workspaceId }, workspaceSelectionSource: source }),
});
expect(created).toMatchObject({ projectId: project.id, executionWorkspaceId: f.workspaceId, projectWorkspaceId: null });
const [original] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(original).toMatchObject({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: f.workspaceId });
const { workspace } = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
expect(workspace).toMatchObject({ projectId: null, projectWorkspaceId: null, cwd: `/tmp/task-${f.issueId}` });
for (const task of [created, original]) {
await expect(assertGitSensitiveAdapterWorkspaceValid({
adapterType: "codex_local", agentId: randomUUID(), issue: task,
resolvedWorkspace: { cwd: workspace!.cwd!, source: "task_session", projectId: null, workspaceId: null,
repoUrl: null, repoRef: null, workspaceHints: [], warnings: [] },
executionWorkspace: { cwd: workspace!.cwd!, baseCwd: workspace!.cwd!, source: "task_session", projectId: null,
workspaceId: null, repoUrl: null, repoRef: null, strategy: "project_primary", branchName: null,
worktreePath: null, warnings: [], created: false, branchCreatedByRuntime: false },
persistedExecutionWorkspace: workspace, executionTarget: { kind: "local" },
})).resolves.toBeUndefined();
}
} finally {
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
}
});
it("replaces an old source with the selected binding's null source on an ordinary update", async () => {
const f = await fixture(), svc = executionWorkspaceService(db), settings = instanceSettingsService(db);
const previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
try {
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Organizational project" }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id,
name: "Previous source", cwd: "/tmp/previous-source" }).returning();
const [oldWorkspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id,
projectWorkspaceId: source.id, name: "Previous folder", cwd: source.cwd, mode: "shared_workspace", strategyType: "project_primary" }).returning();
const [task] = await db.insert(issues).values({ companyId: f.companyId, projectId: project.id,
projectWorkspaceId: source.id, executionWorkspaceId: oldWorkspace.id, title: "Switch to task files" }).returning();
await svc.selectTaskWorkspace({ ...f, issueId: task.id, selection: { kind: "task_directory" },
expectedBindingRevision: 0, requestKey: "superseded-before-raw-binding" });
const updated = await issueService(db).update(task.id, { executionWorkspaceId: f.workspaceId });
expect(updated).toMatchObject({ projectId: project.id, executionWorkspaceId: f.workspaceId, projectWorkspaceId: null,
workspaceBindingRevision: 2, workspacePendingSelection: null });
expect(await svc.applyPendingTaskWorkspaceSelection({ ...f, issueId: task.id, runId: randomUUID() })).toBe(false);
expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).workspace)
.toMatchObject({ id: f.workspaceId, projectId: null, projectWorkspaceId: null, cwd: `/tmp/task-${f.issueId}` });
const [previousWorkspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, oldWorkspace.id));
expect(previousWorkspace).toMatchObject({ projectId: project.id, projectWorkspaceId: source.id, cwd: source.cwd });
} finally {
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
}
});
it("queues explicit root changes without changing the current binding and applies at admission", async () => {
const f = await fixture(), svc = executionWorkspaceService(db);
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const request = { ...f, selection: { kind: "task_directory" as const }, expectedBindingRevision: 1, requestKey: "choose-task-directory" };
expect(await svc.selectTaskWorkspace(request)).toMatchObject({ kind: "scheduled", applies: "next_normal_admission" });
expect(await svc.selectTaskWorkspace(request)).toMatchObject({ kind: "scheduled" });
await expect(svc.selectTaskWorkspace({ ...request, selection: { kind: "existing", workspaceId: f.workspaceId } })).rejects.toThrow("different intent");
expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).workspace?.id).toBe(f.workspaceId);
await svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() });
const view = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
expect(view.workspace).toBeNull();
expect(view.selection).toMatchObject({ selection: { kind: "task_directory" } });
expect(view.bindingRevision).toBe(3);
expect(await svc.selectTaskWorkspace(request)).toMatchObject({ kind: "applied", bindingRevision: 3 });
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]?.metadata)
.toMatchObject({ _issuePrivacySources: { [f.issueId]: true } });
await expect(svc.selectTaskWorkspace({ ...request, requestKey: "stale" })).rejects.toThrow("binding changed");
});
it.each(["before admission", "after admission"] as const)("rejects a superseded selection retry %s without replacing the newer choice", async phase => {
const f = await fixture(), svc = executionWorkspaceService(db);
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const [nextWorkspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId,
mode: "shared_workspace", strategyType: "project_primary", name: "Newer choice", cwd: `/tmp/newer-${f.issueId}` }).returning();
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Running worker" }).returning();
const immutableInput = { cwd: `/tmp/task-${f.issueId}`, binding: { executionWorkspaceId: f.workspaceId } };
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent.id,
status: "running", contextSnapshot: { issueId: f.issueId, persistedNativeExecutionInput: immutableInput } }).returning();
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = f.actor; next(); });
app.use("/api", taskWorkspaceRoutes(db));
app.use(errorHandler);
const select = (body: object) => request(app).put(`/api/issues/${f.issueId}/workspace`).send(body);
const first = { selection: { kind: "task_directory" }, expectedBindingRevision: 1, requestKey: "choice-A" };
const acceptedA = await select(first);
expect(acceptedA.status).toBe(200);
const second = { selection: { kind: "existing", workspaceId: nextWorkspace.id },
expectedBindingRevision: acceptedA.body.bindingRevision, requestKey: "choice-B" };
const acceptedB = await select(second);
expect(acceptedB.status).toBe(200);
const admission = { ...f, runId: randomUUID() };
await expect(svc.applyPendingTaskWorkspaceSelection(admission)).rejects.toThrow("Previous task run must finish");
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({
workspace: { id: f.workspaceId }, pendingSelection: { requestKey: second.requestKey },
});
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toMatchObject({
status: "running", contextSnapshot: { persistedNativeExecutionInput: immutableInput },
});
if (phase === "after admission") {
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id));
expect(await svc.applyPendingTaskWorkspaceSelection(admission)).toBe(true);
}
const beforeRetry = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
const retryA = await select(first);
expect(retryA.status).toBe(409);
expect(retryA.body.error).toContain("binding changed");
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toEqual(beforeRetry);
const retryB = await select(second);
expect(retryB.status).toBe(200);
expect(retryB.body.kind).toBe(phase === "before admission" ? "scheduled" : "applied");
if (phase === "before admission") {
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id));
expect(await svc.applyPendingTaskWorkspaceSelection(admission)).toBe(true);
}
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({
workspace: { id: nextWorkspace.id }, selection: { request: { key: second.requestKey } }, pendingSelection: null,
});
});
describe.each(["pending selection", "legacy update"] as const)("native settlement before %s", (entryPoint) => {
let isolatedWorkspacesEnabled: boolean;
beforeAll(async () => {
isolatedWorkspacesEnabled = (await instanceSettingsService(db).getExperimental()).enableIsolatedWorkspaces;
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true });
});
afterAll(async () => { await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: isolatedWorkspacesEnabled }); });
async function settledNativeTask(failureCode = "native_retry_cancelled", acceptedResult = false) {
const f = await fixture(), svc = executionWorkspaceService(db);
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Stopped worker" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent.id,
nativeIssueId: f.issueId, runtimeMode: "native", nativePhase: "terminal_failure", status: "cancelled",
contextSnapshot: {}, finishedAt: new Date() }).returning();
let resultId: string | null = null;
if (acceptedResult) {
const [contract] = await db.insert(completionContracts).values({ companyId: f.companyId, issueId: f.issueId,
revision: 1, schemaVersion: "paperclip.completion-contract.v1", policyVersion: "test", risk: "low",
completionAuthority: "agent", incompleteCriteriaPolicy: "block", contractJson: {}, canonicalSha256: "contract",
createdByActorType: "agent", createdByActorId: agent.id }).returning();
await db.update(heartbeatRuns).set({ completionContractId: contract.id }).where(eq(heartbeatRuns.id, run.id));
const [result] = await db.insert(nativeRunResults).values({ companyId: f.companyId, issueId: f.issueId, runId: run.id,
completionContractId: contract.id, serverFingerprint: randomUUID(), schemaStatus: "accepted", resultJson: {}, canonicalSha256: "result" }).returning();
resultId = result.id;
}
await db.insert(nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issueId, runId: run.id,
phase: "terminal_failure", failureCode, resultId });
const [lease] = await db.insert(environmentLeases).values({ companyId: f.companyId, issueId: f.issueId,
heartbeatRunId: run.id, executionWorkspaceId: f.workspaceId, status: "released", cleanupStatus: "success", releasedAt: new Date() }).returning();
if (entryPoint === "pending selection") await svc.selectTaskWorkspace({ ...f, selection: { kind: "task_directory" }, expectedBindingRevision: 1, requestKey: "after-stop" });
const change = () => entryPoint === "pending selection"
? svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() })
: issueService(db).update(f.issueId, { executionWorkspaceId: null });
return { ...f, run, lease, svc, change };
}
it.each(["native_retry_cancelled", "native_permission_declined"])("allows a settled %s without deleting its historical failure", async (failureCode) => {
const f = await settledNativeTask(failureCode);
await f.change();
expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: entryPoint === "pending selection" ? 3 : 2, pendingSelection: null });
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.run.id)))[0])
.toMatchObject({ phase: "terminal_failure", failureCode });
});
it.each(["running native owner", "coordinator owner", "applied result", "pending cleanup", "failed cleanup", "export marker", "active lease", "failed output", "accepted output"] as const)("keeps %s fenced until its durable obligation settles", async (obligation) => {
const f = await settledNativeTask("native_retry_cancelled", obligation === "accepted output");
if (obligation === "running native owner") await db.update(heartbeatRuns).set({ status: "running" }).where(eq(heartbeatRuns.id, f.run.id));
if (obligation === "coordinator owner") await db.update(nativeRunFinalizations).set({ leaseOwner: "controller" }).where(eq(nativeRunFinalizations.runId, f.run.id));
if (obligation === "applied result") await db.update(nativeRunFinalizations).set({ phase: "applied" }).where(eq(nativeRunFinalizations.runId, f.run.id));
if (obligation === "pending cleanup") await db.update(environmentLeases).set({ status: "pending_cleanup" }).where(eq(environmentLeases.id, f.lease.id));
if (obligation === "failed cleanup") await db.update(environmentLeases).set({ cleanupStatus: "failed" }).where(eq(environmentLeases.id, f.lease.id));
if (obligation === "export marker") await db.update(environmentLeases).set({ metadata: { nativeWorkspaceExportResume: { runId: f.run.id } } }).where(eq(environmentLeases.id, f.lease.id));
if (obligation === "active lease") await db.update(environmentLeases).set({ status: "active", releasedAt: null }).where(eq(environmentLeases.id, f.lease.id));
if (obligation === "failed output") await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId,
heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "failed" });
await expect(f.change()).rejects.toThrow(/workspace|Previous task run/);
expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: { id: f.workspaceId }, bindingRevision: entryPoint === "pending selection" ? 2 : 1 });
if (obligation === "failed output" || obligation === "accepted output") {
await db.update(workspaceOperations).set({ createdAt: new Date(0) }).where(eq(workspaceOperations.heartbeatRunId, f.run.id));
await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId,
heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "succeeded", finishedAt: new Date() });
await f.change();
expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: entryPoint === "pending selection" ? 3 : 2 });
}
});
it.each(["warm release", "unsafe export omission"] as const)("accepts a settled %s receipt", async (receipt) => {
const f = await settledNativeTask("native_retry_cancelled", receipt === "unsafe export omission");
if (receipt === "warm release") await db.update(environmentLeases).set({ releasedAt: null, status: "retained", leasePolicy: "reuse_by_environment", cleanupStatus: "success" }).where(eq(environmentLeases.id, f.lease.id));
if (receipt === "unsafe export omission") await db.insert(workspaceOperations).values({ companyId: f.companyId, issueId: f.issueId,
heartbeatRunId: f.run.id, executionWorkspaceId: f.workspaceId, phase: "workspace_finalize", status: "succeeded",
metadata: { workspaceSync: { omitted: true } }, finishedAt: new Date() });
await f.change();
expect(await f.svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ workspace: null, bindingRevision: entryPoint === "pending selection" ? 3 : 2 });
});
});
it("preserves a queued next-run choice through first binding and its original retry receipt", async () => {
const f = await fixture(), svc = executionWorkspaceService(db);
const [nextWorkspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId,
mode: "shared_workspace", strategyType: "project_primary", name: "Next folder", cwd: `/tmp/next-${f.issueId}` }).returning();
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "First worker", adapterType: "process" }).returning();
const [run] = await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent.id,
status: "running", invocationSource: "on_demand", contextSnapshot: { issueId: f.issueId } }).returning();
const selectionRequest = { ...f, selection: { kind: "existing" as const, workspaceId: nextWorkspace.id },
expectedBindingRevision: 0, requestKey: "choose-during-first-admission" };
await svc.selectTaskWorkspace(selectionRequest);
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({
workspace: { id: f.workspaceId }, bindingRevision: 2,
pendingSelection: { expectedBindingRevision: 2, intent: { request: { expectedBindingRevision: 0 } } },
});
expect(await svc.selectTaskWorkspace(selectionRequest)).toMatchObject({ kind: "scheduled", bindingRevision: 2 });
await expect(svc.selectTaskWorkspace({ ...selectionRequest, expectedBindingRevision: 1 })).rejects.toThrow("different intent");
const admission = { ...f, runId: randomUUID() };
await expect(svc.applyPendingTaskWorkspaceSelection(admission)).rejects.toThrow("Previous task run must finish");
await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, run.id));
expect(await svc.applyPendingTaskWorkspaceSelection(admission)).toBe(true);
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({
workspace: { id: nextWorkspace.id }, bindingRevision: 3, pendingSelection: null,
});
expect(await svc.selectTaskWorkspace(selectionRequest)).toMatchObject({ kind: "applied", bindingRevision: 3 });
await svc.selectTaskWorkspace({ ...f, selection: { kind: "task_directory" },
expectedBindingRevision: 3, requestKey: "later-choice" });
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
await expect(svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() })).rejects.toThrow("Pending workspace selection is stale");
});
it("allows regular signed-in members to select files and request repositories while denying viewers", async () => {
const f = await fixture(), svc = executionWorkspaceService(db), userId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Board member", email: `${userId}@example.test`,
createdAt: new Date(), updatedAt: new Date() });
const [membership] = await db.insert(companyMemberships).values({ companyId: f.companyId,
principalType: "user", principalId: userId, status: "active", membershipRole: "member" }).returning();
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const actor = { type: "board" as const, source: "session" as const, userId,
companyIds: [f.companyId], isInstanceAdmin: false };
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", taskWorkspaceRoutes(db));
app.use(errorHandler);
const [assignee] = await db.insert(agents).values({ companyId: f.companyId, name: "Assigned worker", status: "idle" }).returning();
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected files",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id,
name: "Source", cwd: `/tmp/task-${f.issueId}` }).returning();
await db.update(issues).set({ assigneeAgentId: assignee.id }).where(eq(issues.id, f.issueId));
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id }).where(eq(executionWorkspaces.id, f.workspaceId));
const selection = { selection: { kind: "existing" as const, workspaceId: f.workspaceId }, expectedBindingRevision: 1, requestKey: "member-selection" };
const repository = { repository: { kind: "url" as const, url: "https://github.com/public/example" }, requestKey: "member-repository" };
expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection)).status).toBe(403);
expect((await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(repository)).status).toBe(403);
const channelDefaults = { companyId: f.companyId, actor, assigneeAgentId: assignee.id,
defaults: { workspace: { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "shared" as const } } };
await expect(assertChatExecutionDefaultsAccess(db, channelDefaults)).rejects.toThrow(/protected/);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: userId,
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: assignee.id } });
await expect(assertChatExecutionDefaultsAccess(db, channelDefaults)).resolves.toBeUndefined();
await expect(assertChatExecutionDefaultsAccess(db, { ...channelDefaults, assigneeAgentId: null })).rejects.toThrow(/protected/);
expect(await issueService(db).create(f.companyId, { title: "Board-assigned shared work", createdByUserId: userId,
assigneeAgentId: assignee.id, workspaceSelectionActor: actor,
workspaceSelection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" } }))
.toMatchObject({ assigneeAgentId: assignee.id, projectWorkspaceId: source.id });
const selected = await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection);
expect(selected.status, JSON.stringify(selected.body)).toBe(200);
expect(selected.body).toMatchObject({ kind: "scheduled" });
const prepared = await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(repository);
expect(prepared.status, JSON.stringify(prepared.body)).toBe(200);
expect(prepared.body).toMatchObject({ kind: "requires_next_admission" });
const before = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
expect(before).toMatchObject({ workspace: { id: f.workspaceId }, pendingSelection: { requestKey: selection.requestKey } });
expect(await svc.listTaskRepositories(f.companyId, f.workspaceId)).toHaveLength(1);
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.id, membership.id));
const deniedSelection = { ...selection, requestKey: "viewer-selection" };
const deniedRepository = { ...repository, requestKey: "viewer-repository" };
expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send(deniedSelection)).status).toBe(403);
expect((await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(deniedRepository)).status).toBe(403);
await expect(svc.selectTaskWorkspace({ ...f, actor, ...deniedSelection })).rejects.toThrow("modification is not allowed");
await expect(svc.requestTaskRepository({ ...f, actor, request: deniedRepository })).rejects.toThrow("modification is not allowed");
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toEqual(before);
expect(await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, f.workspaceId))).toHaveLength(1);
});
it.each(["binding", "configured_source"] as const)("enforces source authority on legacy PATCH %s and rolls back denied edits", async kind => {
const f = await fixture(), userId = randomUUID(), settings = instanceSettingsService(db);
const previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
try {
await db.insert(authUsers).values({ id: userId, name: "Workspace selector", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
const [assignee, nextAssignee] = await db.insert(agents).values(["Current worker", "Next worker"].map(name => ({ companyId: f.companyId, name, status: "idle", runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }))).returning();
await db.update(issues).set({ assigneeAgentId: assignee.id }).where(eq(issues.id, f.issueId));
const [project, organization] = await db.insert(projects).values([
{ companyId: f.companyId, name: "Protected source", executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } },
{ companyId: f.companyId, name: "Organization" },
]).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: `/tmp/source-${f.issueId}` }).returning();
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id }).where(eq(executionWorkspaces.id, f.workspaceId));
const actor = { type: "board" as const, source: "session" as const, userId, companyIds: [f.companyId], isInstanceAdmin: false };
const beforeMetadata = (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0].metadata;
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", taskWorkspaceRoutes(db));
app.use("/api", issueRoutes(db, {} as never));
app.use(errorHandler);
const patch = kind === "binding" ? { executionWorkspaceId: f.workspaceId } : { projectWorkspaceId: source.id, executionWorkspaceSettings: { mode: "shared_workspace" } };
const selection = kind === "binding" ? { kind: "existing", workspaceId: f.workspaceId } : { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" };
expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send({ selection, expectedBindingRevision: 0, requestKey: "denied" })).status).toBe(403);
const denied = await request(app).patch(`/api/issues/${f.issueId}`).send(patch);
expect(denied.status, JSON.stringify(denied.body)).toBe(403);
const readTask = () => db.select().from(issues).where(eq(issues.id, f.issueId)).then(rows => rows[0]);
expect(await readTask()).toMatchObject({ executionWorkspaceId: null, projectWorkspaceId: null, workspaceBindingRevision: 0 });
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0].metadata).toEqual(beforeMetadata);
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: assignee.id } }).returning();
await expect(issueService(db).update(f.issueId, { ...patch, assigneeAgentId: nextAssignee.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
expect(await readTask()).toMatchObject({ assigneeAgentId: assignee.id, workspaceBindingRevision: 0 });
const allowed = await request(app).patch(`/api/issues/${f.issueId}`).send(patch);
expect(allowed.status, JSON.stringify(allowed.body)).toBe(200);
const bound = await readTask();
expect(bound).toMatchObject({ ...patch, projectWorkspaceId: source.id, workspaceBindingRevision: 1 });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
const organized = await request(app).patch(`/api/issues/${f.issueId}`).send({ projectId: organization.id });
expect(organized.status, JSON.stringify(organized.body)).toBe(200);
expect(await readTask()).toMatchObject({ projectId: organization.id, executionWorkspaceId: bound.executionWorkspaceId, projectWorkspaceId: source.id, workspaceBindingRevision: 1 });
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); }
});
it.each(["create", "patch", "selection"] as const)("uses the assigned agent's effective source strategy for %s authorization", async operation => {
const f = await fixture(), userId = randomUUID(), settings = instanceSettingsService(db);
const previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: true, enableIsolatedWorkspacesByDefault: false });
try {
await db.insert(authUsers).values({ id: userId, name: "Source reader", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
const [isolatedAgent, sharedAgent] = await db.insert(agents).values(["git_worktree", "project_primary"].map(type => ({
companyId: f.companyId, name: type, status: "idle", adapterConfig: { workspaceStrategy: { type } },
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } },
}))).returning();
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Read-only source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: `/tmp/source-${f.issueId}` }).returning();
const actor = { type: "board" as const, source: "session" as const, userId, companyIds: [f.companyId], isInstanceAdmin: false };
const sharedOverrides = { adapterConfig: { workspaceStrategy: { type: "project_primary" } } };
const tasks = issueService(db), workspaces = executionWorkspaceService(db);
if (operation === "create") {
const input = { title: "Read source in separate checkout", assigneeAgentId: isolatedAgent.id,
projectWorkspaceId: source.id, workspaceSelectionActor: actor, createdByUserId: userId };
await expect(tasks.create(f.companyId, { ...input, assigneeAdapterOverrides: sharedOverrides })).rejects.toThrow(/protected/);
await expect(tasks.create(f.companyId, input)).resolves.toMatchObject({ assigneeAgentId: isolatedAgent.id, projectWorkspaceId: source.id });
await expect(tasks.create(f.companyId, { ...input, workspaceSelection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "managed_isolated" },
assigneeAdapterOverrides: sharedOverrides })).rejects.toThrow(/protected/);
} else if (operation === "patch") {
await db.update(issues).set({ assigneeAgentId: sharedAgent.id }).where(eq(issues.id, f.issueId));
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", issueRoutes(db, {} as never)); app.use(errorHandler);
const patch = { projectWorkspaceId: source.id, assigneeAgentId: isolatedAgent.id };
const denied = await request(app).patch(`/api/issues/${f.issueId}`).send({ ...patch, assigneeAdapterOverrides: sharedOverrides });
expect(denied.status, JSON.stringify(denied.body)).toBe(403);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ assigneeAgentId: sharedAgent.id, projectWorkspaceId: null });
const allowed = await request(app).patch(`/api/issues/${f.issueId}`).send(patch);
expect(allowed.status, JSON.stringify(allowed.body)).toBe(200);
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0]).toMatchObject({ assigneeAgentId: isolatedAgent.id, projectWorkspaceId: source.id });
} else {
await db.update(issues).set({ assigneeAgentId: isolatedAgent.id, assigneeAdapterOverrides: sharedOverrides }).where(eq(issues.id, f.issueId));
const input = { companyId: f.companyId, issueId: f.issueId, actor,
selection: { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "managed_isolated" as const } };
await expect(workspaces.validateSelection(input)).rejects.toThrow(/protected/);
await db.update(issues).set({ assigneeAdapterOverrides: null }).where(eq(issues.id, f.issueId));
await expect(workspaces.validateSelection(input)).resolves.toMatchObject({ projectWorkspaceId: source.id });
await db.update(issues).set({ assigneeAgentId: sharedAgent.id }).where(eq(issues.id, f.issueId));
await expect(workspaces.validateSelection(input)).rejects.toThrow(/protected/);
}
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces, enableIsolatedWorkspacesByDefault: previous.enableIsolatedWorkspacesByDefault }); }
});
it("requires source project assignment authority for shared files while preserving isolated reads", async () => {
const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db);
const settings = instanceSettingsService(db), previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
try {
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Read-only source user", adapterType: "process", status: "idle" }).returning();
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", principalId: agent.id, status: "active", membershipRole: "member" });
const actor = { type: "agent" as const, agentId: agent.id, companyId: f.companyId, source: "agent_key" as const };
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id,
name: "Shared source", cwd: `/tmp/protected-${f.issueId}` }).returning();
const [shared] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id,
projectWorkspaceId: source.id, name: "Shared files", cwd: source.cwd, mode: "shared_workspace", strategyType: "project_primary" }).returning();
const [isolated] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id,
projectWorkspaceId: source.id, name: "Isolated files", cwd: `/tmp/isolated-${f.issueId}`, mode: "isolated_workspace", strategyType: "git_worktree" }).returning();
expect(await authorizationService(db).decide({ actor, action: "project:read",
resource: { type: "project", companyId: f.companyId, projectId: project.id } })).toMatchObject({ allowed: true });
const sharedSelection = { kind: "configured_source" as const, projectWorkspaceId: source.id, mode: "shared" as const };
const isolatedSelection = { ...sharedSelection, mode: "managed_isolated" as const };
await expect(svc.validateSelection({ ...f, actor, selection: sharedSelection })).rejects.toThrow(/protected/);
await expect(svc.validateSelection({ ...f, actor, selection: isolatedSelection })).resolves.toMatchObject({ executionWorkspaceSettings: { mode: "isolated_workspace" } });
await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: shared.id } })).rejects.toThrow(/protected/);
await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: isolated.id } })).resolves.toMatchObject({ executionWorkspaceId: isolated.id });
for (const sourceKind of ["explicit", "channel"] as const) {
await expect(tasks.create(f.companyId, { title: `Denied ${sourceKind}`, createdByAgentId: agent.id,
workspaceSelection: sharedSelection, workspaceSelectionSource: sourceKind, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
expect(await tasks.create(f.companyId, { title: `Isolated ${sourceKind}`, createdByAgentId: agent.id,
workspaceSelection: isolatedSelection, workspaceSelectionSource: sourceKind, workspaceSelectionActor: actor })).toMatchObject({ projectId: null, projectWorkspaceId: source.id });
}
await expect(tasks.create(f.companyId, { title: "Denied legacy source", createdByAgentId: agent.id,
projectWorkspaceId: source.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
await expect(tasks.create(f.companyId, { title: "Denied legacy binding", createdByAgentId: agent.id,
executionWorkspaceId: shared.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
await db.update(executionWorkspaces).set({ mode: "isolated_workspace" }).where(eq(executionWorkspaces.id, shared.id));
await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: shared.id } })).rejects.toThrow(/protected/);
await expect(tasks.create(f.companyId, { title: "Mislabelled isolated source", createdByAgentId: agent.id,
projectWorkspaceId: source.id, workspaceSelectionActor: actor,
executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "project_primary" } } })).rejects.toThrow(/protected/);
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace",
workspaceStrategy: { type: "project_primary" }, authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(projects.id, project.id));
await expect(svc.validateSelection({ ...f, actor, selection: isolatedSelection })).rejects.toThrow(/protected/);
await db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(projects.id, project.id));
const [organization] = await db.insert(projects).values({ companyId: f.companyId, name: "Task organization" }).returning();
await db.update(issues).set({ projectId: organization.id }).where(eq(issues.id, f.issueId));
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent",
principalId: agent.id, permissionKey: "tasks:assign_scope", scope: { projectId: organization.id, assigneeAgentId: agent.id } }).returning();
const selectionRequest = { ...f, actor, selection: sharedSelection, expectedBindingRevision: 0, requestKey: "authorized-shared" };
await expect(svc.selectTaskWorkspace(selectionRequest)).rejects.toThrow(/protected/);
const [otherAgent] = await db.insert(agents).values({ companyId: f.companyId, name: "Another assignee", status: "idle" }).returning();
await db.update(issues).set({ assigneeAgentId: otherAgent.id }).where(eq(issues.id, f.issueId));
await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: otherAgent.id } })
.where(eq(principalPermissionGrants.id, grant.id));
await expect(svc.selectTaskWorkspace(selectionRequest)).rejects.toThrow(/protected/);
await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: agent.id } })
.where(eq(principalPermissionGrants.id, grant.id));
await expect(svc.selectTaskWorkspace(selectionRequest)).resolves.toMatchObject({ kind: "scheduled" });
const responsibleUserId = randomUUID();
await db.insert(authUsers).values({ id: responsibleUserId, name: "Source owner", email: `${responsibleUserId}@example.test`,
createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: responsibleUserId,
status: "active", membershipRole: "member" });
const delegated = { ...actor, onBehalfOfUserId: responsibleUserId };
await expect(svc.validateSelection({ ...f, actor: delegated, selection: sharedSelection })).rejects.toThrow(/Responsible user.*not authorized/);
const [userGrant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user",
principalId: responsibleUserId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: agent.id } }).returning();
await expect(svc.validateSelection({ ...f, actor: delegated, selection: sharedSelection })).resolves.toMatchObject({ projectWorkspaceId: source.id });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, userGrant.id));
await expect(svc.validateSelection({ ...f, actor: delegated, selection: sharedSelection })).rejects.toThrow(/Responsible user.*not authorized/);
expect(await tasks.create(f.companyId, { title: "Authorized shared source", createdByAgentId: agent.id,
workspaceSelection: sharedSelection, workspaceSelectionActor: actor })).toMatchObject({ projectId: null, projectWorkspaceId: source.id });
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
await expect(svc.applyPendingTaskWorkspaceSelection({ ...f, actor, runId: randomUUID() })).rejects.toThrow(/protected/);
await expect(assertTaskWorkspaceAccess(db, actor, f.companyId, shared.id)).resolves.toBeUndefined();
await expect(assertTaskWorkspaceAccess(db, actor, f.companyId, shared.id, { write: true })).rejects.toThrow(/protected/);
await expect(assertTaskWorkspaceAccess(db, actor, f.companyId, isolated.id, { write: true })).resolves.toBeUndefined();
expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).workspace).toBeNull();
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); }
});
it.each(["binding", "configured_source"] as const)("supersedes an older queued selection when an ordinary update changes %s", async kind => {
const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db);
const settings = instanceSettingsService(db), previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
try {
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const request = { ...f, selection: { kind: "task_directory" as const }, expectedBindingRevision: 1, requestKey: "older-choice" };
await svc.selectTaskWorkspace(request);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "New source" }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "New files", cwd: `/tmp/new-source-${f.issueId}` }).returning();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: null,
mode: "shared_workspace", strategyType: "project_primary", name: "New binding", cwd: `/tmp/new-binding-${f.issueId}` }).returning();
const patch = kind === "binding" ? { executionWorkspaceId: workspace.id } : { executionWorkspaceId: null, projectWorkspaceId: source.id };
await tasks.update(f.issueId, patch);
expect(await svc.applyPendingTaskWorkspaceSelection({ ...f, runId: randomUUID() })).toBe(false);
const view = await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor);
expect(view).toMatchObject({ bindingRevision: 3, pendingSelection: null, selection: null });
expect(view.workspace?.id ?? null).toBe(kind === "binding" ? workspace.id : null);
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(task.projectWorkspaceId).toBe(kind === "configured_source" ? source.id : null);
await expect(svc.selectTaskWorkspace(request)).rejects.toThrow("binding changed");
await tasks.update(f.issueId, { projectId: project.id, ...patch });
expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).bindingRevision).toBe(3);
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); }
});
it("rejects an ordinary source annotation on bound files and preserves active run ownership", async () => {
const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db);
await svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Other source" }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Other files", cwd: `/tmp/other-${f.issueId}` }).returning();
await expect(tasks.update(f.issueId, { projectWorkspaceId: source.id })).rejects.toThrow("already has bound files");
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Running worker", adapterType: "process", status: "active" }).returning();
await db.insert(heartbeatRuns).values({ companyId: f.companyId, agentId: agent.id, status: "running", invocationSource: "on_demand", nativeIssueId: f.issueId, contextSnapshot: { issueId: f.issueId } });
const settings = instanceSettingsService(db), previous = await settings.getExperimental();
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
try {
await expect(tasks.update(f.issueId, { executionWorkspaceId: null, projectWorkspaceId: source.id })).rejects.toThrow("still owns its workspace");
expect(await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).toMatchObject({ bindingRevision: 1, workspace: { id: f.workspaceId } });
await expect(svc.selectTaskWorkspace({ ...f, selection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" },
expectedBindingRevision: 1, requestKey: "next-admission" })).resolves.toMatchObject({ kind: "scheduled" });
} finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); }
});
it("defaults app and chat tasks to independent directories without hydrating legacy agent files", async () => {
const f = await fixture();
const root = await fs.mkdtemp(path.join(os.tmpdir(), "task-workspace-default-"));
vi.stubEnv("PAPERCLIP_HOME", root);
try {
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "File worker", adapterType: "process", status: "idle" }).returning();
const legacy = resolveDefaultAgentWorkspaceDir(agent!.id);
await fs.mkdir(legacy, { recursive: true });
const old = await fs.open(path.join(legacy, "unrelated.bin"), "w");
await old.truncate(1_200_000_000); await old.close();
const resolver = createHeartbeatWorkspaceResolver(db);
const first = await resolver.resolveWorkspaceForRun(agent!, { issueId: f.issueId }, null);
expect(first.cwd).not.toBe(legacy);
expect(await fs.readdir(first.cwd)).toEqual([]);
await fs.writeFile(path.join(first.cwd, "report.txt"), "task output");
const again = await resolver.resolveWorkspaceForRun(agent!, { issueId: f.issueId }, null);
expect(again.cwd).toBe(first.cwd);
const chatId = randomUUID();
await db.insert(issues).values({ id: chatId, companyId: f.companyId, title: "Chat report", originKind: "chat_channel" });
const chat = await resolver.resolveWorkspaceForRun(agent!, { issueId: chatId }, null);
expect(chat.cwd).not.toBe(first.cwd);
expect(path.dirname(chat.cwd)).toBe(path.dirname(first.cwd));
const organizationId = randomUUID();
await db.insert(projects).values({ id: organizationId, companyId: f.companyId, name: "Organization only" });
await db.update(issues).set({ projectId: organizationId }).where(eq(issues.id, chatId));
const organized = await resolver.resolveWorkspaceForRun(agent!, { issueId: chatId }, null);
expect(organized.cwd).toBe(chat.cwd);
expect(organized.projectId).toBeNull();
const nativeChatScope = await findNativeChatWorkspaceScope(db, {
adapterType: "paperclip_runner", environmentDriver: "local", companyId: f.companyId,
agentId: agent!.id, issueId: chatId, instanceRoot: root,
});
expect(nativeChatScope?.projectId).toBeNull();
expect((await fs.stat(path.join(legacy, "unrelated.bin"))).size).toBe(1_200_000_000);
} finally { vi.unstubAllEnvs(); await fs.rm(root, { recursive: true, force: true }); }
});
it.each(["missing_path", "clone_failure", "deleted_source"] as const)("refuses sibling and session fallbacks for an explicit configured source: %s", async (failure) => {
const f = await fixture();
const root = await fs.mkdtemp(path.join(os.tmpdir(), "task-source-selection-"));
vi.stubEnv("PAPERCLIP_HOME", root);
try {
const projectId = randomUUID(), sourceId = randomUUID(), siblingId = randomUUID();
const siblingCwd = path.join(root, "usable-sibling");
await fs.mkdir(siblingCwd);
await fs.writeFile(path.join(siblingCwd, "source.txt"), "Other repository");
await db.insert(projects).values({ id: projectId, companyId: f.companyId, name: "Multiple sources" });
await db.insert(projectWorkspaces).values([
{ id: sourceId, companyId: f.companyId, projectId, name: "Selected", isPrimary: true,
sourceType: failure === "clone_failure" ? "git_repo" : "local_path",
cwd: failure === "clone_failure" ? null : path.join(root, "missing-source"),
repoUrl: failure === "clone_failure" ? path.join(root, "missing-remote.git") : null },
{ id: siblingId, companyId: f.companyId, projectId, name: "Other", sourceType: "local_path", cwd: siblingCwd },
]);
await db.update(issues).set({ projectId, projectWorkspaceId: sourceId,
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "shared" } },
}).where(eq(issues.id, f.issueId));
if (failure === "deleted_source") await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, sourceId));
const [agent] = await db.insert(agents).values({ companyId: f.companyId, name: "Worker", adapterType: "process",
adapterConfig: { cwd: siblingCwd } }).returning();
await expect(createHeartbeatWorkspaceResolver(db).resolveWorkspaceForRun(agent!, { issueId: f.issueId }, { cwd: siblingCwd }))
.rejects.toMatchObject({ code: "workspace_validation_failed", resultJson: { workspaceValidation: {
reason: failure === "clone_failure" ? "git_worktree_base_materialization_failed" : "configured_workspace_unavailable",
issueProjectWorkspaceId: sourceId, baseCwdFallback: false,
materializationFailures: failure === "clone_failure" ? [expect.objectContaining({ projectWorkspaceId: sourceId })] : [],
} } });
expect(await fs.readFile(path.join(siblingCwd, "source.txt"), "utf8")).toBe("Other repository");
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].executionWorkspaceId).toBeNull();
if (failure === "missing_path") {
await fs.mkdir(path.join(root, "missing-source"));
const repaired = await createHeartbeatWorkspaceResolver(db).resolveWorkspaceForRun(agent!, { issueId: f.issueId }, { cwd: siblingCwd });
expect(repaired).toMatchObject({ workspaceId: sourceId, cwd: path.join(root, "missing-source"), baseCwdFallback: false });
}
} finally { vi.unstubAllEnvs(); await fs.rm(root, { recursive: true, force: true }); }
});
it("retains every task privacy source when simultaneous tasks bind the same folder", async () => {
const f = await fixture(), secondIssueId = randomUUID(), svc = executionWorkspaceService(db);
await db.insert(issues).values({ id: secondIssueId, companyId: f.companyId, title: "Second task" });
await Promise.all([svc.bindTaskWorkspace(f.companyId, f.issueId, f.workspaceId), svc.bindTaskWorkspace(f.companyId, secondIssueId, f.workspaceId)]);
const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId));
expect(workspace?.metadata).toMatchObject({ _issuePrivacySources: { [f.issueId]: true, [secondIssueId]: true } });
});
it.each(["creation", "selection"] as const)("retains private writers after an existing workspace is installed by %s and then detached", async (installation) => {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
const f = await fixture(), svc = executionWorkspaceService(db);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Organization" }).returning();
const [reader] = await db.insert(agents).values({ companyId: f.companyId, name: "Unrelated reader", adapterType: "process" }).returning();
const writer = await issueService(db).create(f.companyId, {
title: "Private writer", projectId: project.id, visibility: "private", responsibleUserId: "private-owner",
workspaceSelectionActor: f.actor,
...(installation === "creation" ? { workspaceSelection: { kind: "existing" as const, workspaceId: f.workspaceId } } : {}),
});
if (installation === "selection") {
await svc.selectTaskWorkspace({ ...f, issueId: writer.id, selection: { kind: "existing", workspaceId: f.workspaceId },
expectedBindingRevision: 0, requestKey: "install-existing" });
await svc.applyPendingTaskWorkspaceSelection({ ...f, issueId: writer.id, runId: randomUUID() });
}
const installed = await svc.inspectTaskWorkspace(f.companyId, writer.id, f.actor);
expect(installed.workspace?.id).toBe(f.workspaceId);
expect(installed.workspace?.metadata?._issuePrivacySources).toMatchObject({ [writer.id]: true });
await svc.selectTaskWorkspace({ ...f, issueId: writer.id, selection: { kind: "task_directory" },
expectedBindingRevision: installed.bindingRevision, requestKey: "detach-private-writer" });
await svc.applyPendingTaskWorkspaceSelection({ ...f, issueId: writer.id, runId: randomUUID() });
const [retained] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId));
expect(retained.metadata?._issuePrivacySources).toMatchObject({ [writer.id]: true });
expect(await canActorReadExecutionWorkspace(db, { type: "agent", agentId: reader.id, companyId: f.companyId }, retained.id)).toBe(false);
} finally { vi.unstubAllEnvs(); }
});
it("deletes a whole company in source-workspace dependency order", async () => {
const f = await fixture(), sourceProjectId = randomUUID();
await db.insert(projects).values({ id: sourceProjectId, companyId: f.companyId, name: "Source policy" });
await db.update(executionWorkspaces).set({ projectId: sourceProjectId }).where(eq(executionWorkspaces.id, f.workspaceId));
expect(await deleteCompany(db, f.companyId)).toMatchObject({ id: f.companyId });
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId))).toEqual([]);
expect(await db.select().from(projects).where(eq(projects.id, sourceProjectId))).toEqual([]);
});
it("retains source-project policy until workspaces are explicitly cleaned up", async () => {
const f = await fixture(), sourceProjectId = randomUUID();
await db.insert(projects).values({ id: sourceProjectId, companyId: f.companyId, name: "Source policy" });
await db.update(executionWorkspaces).set({ projectId: sourceProjectId }).where(eq(executionWorkspaces.id, f.workspaceId));
await expect(db.delete(projects).where(eq(projects.id, sourceProjectId))).rejects.toThrow();
expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0]?.projectId).toBe(sourceProjectId);
});
it("rejects foreign-company selections before changing pending state", async () => {
const f = await fixture(), other = await fixture(), svc = executionWorkspaceService(db);
await expect(svc.selectTaskWorkspace({ ...f, expectedBindingRevision: 0, requestKey: "foreign",
selection: { kind: "existing", workspaceId: other.workspaceId } })).rejects.toThrow("unavailable or inaccessible");
expect((await svc.inspectTaskWorkspace(f.companyId, f.issueId, f.actor)).pendingSelection).toBeNull();
});
});