mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
Use the existing same-agent continuation and status ledger for deferred repository setup. Preserve the accepted explanation and verified chat destination across its next admission. Co-Authored-By: Paperclip <noreply@paperclip.ing>
164 lines
16 KiB
TypeScript
164 lines
16 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces } from "@paperclipai/db";
|
|
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
|
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
|
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("task workspace API and native semantic authority", () => {
|
|
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
|
|
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
|
beforeAll(async () => { process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID(); server = await startRunnerApiTestServer(); }, 60_000);
|
|
afterAll(async () => { await server?.close(); if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret; });
|
|
it("creates an explicit-workspace child through the authenticated low-trust API without repeating its saved policy", async () => {
|
|
const f = await server.fixture({ connectionScenario: "fresh", disableWakeOnDemand: true });
|
|
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
|
|
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId, projectIds: [f.projectId] } } };
|
|
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: f.projectId,
|
|
projectWorkspaceId: f.projectWorkspaceId, sourceIssueId: f.issueId, name: "Review files", cwd: f.workspace,
|
|
mode: "isolated_workspace", strategyType: "task_directory" }).returning();
|
|
await server.db.update(projects).set({ executionWorkspacePolicy: policy }).where(eq(projects.id, f.projectId));
|
|
await server.db.update(issues).set({ executionPolicy: policy, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
|
|
await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId);
|
|
const create = (parentId: string) => fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
|
|
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
body: JSON.stringify({ title: `Review child ${randomUUID()}`, parentId, projectId: f.projectId,
|
|
assigneeAgentId: f.agentId, status: "backlog", workspaceSelection: { kind: "existing", workspaceId: workspace.id } }),
|
|
});
|
|
const response = await create(f.issueId), body = await response.json();
|
|
expect({ status: response.status, body }).toMatchObject({ status: 201, body: {
|
|
parentId: f.issueId, executionWorkspaceId: workspace.id,
|
|
executionPolicy: { authorizationPolicy: { trustBoundary: policy.authorizationPolicy.trustBoundary } },
|
|
} });
|
|
const denied = await create(f.blockerId);
|
|
expect(denied.status).toBe(403);
|
|
});
|
|
it("inspects an idle admitted workspace, requests a repository, and stages root selection without interrupting", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: null, sourceIssueId: f.issueId, name: "Task files", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory", status: "idle" }).returning();
|
|
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
|
|
const authority = new PaperclipRunnerToolAuthority(server.db, { companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, runId: f.runId, apiUrl: server.apiUrl, workspaceRoot: "/admitted/task" });
|
|
const call = (tool: string, args: Record<string, unknown>) => authority.execute({ tool, arguments: args, callId: randomUUID() });
|
|
const view = await call("get_workspace", {}) as { bindingRevision: number; cwd: string; workspace: { id: string } };
|
|
expect(view).toMatchObject({ cwd: "/admitted/task", workspace: { id: workspace.id } });
|
|
const args = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "repository-one" };
|
|
const first = await call("prepare_repository", args) as { preparationId: string; kind: string };
|
|
expect(first.kind).toBe("requires_next_admission");
|
|
expect(await call("prepare_repository", args)).toMatchObject({ preparationId: first.preparationId });
|
|
const continued = await call("get_task_context", {}) as { completionContinuation: { kind: string; idempotencyKey: string } };
|
|
expect(continued.completionContinuation).toMatchObject({ kind: "same_agent" });
|
|
expect((await call("get_task_context", {}) as typeof continued).completionContinuation).toEqual(continued.completionContinuation);
|
|
await server.db.update(executionWorkspaceRepositories).set({ requestedByIssueId: f.blockerId }).where(eq(executionWorkspaceRepositories.id, first.preparationId));
|
|
expect(await call("get_task_context", {})).toMatchObject({ completionContinuation: null });
|
|
await server.db.update(executionWorkspaceRepositories).set({ requestedByIssueId: f.issueId, state: "ready" }).where(eq(executionWorkspaceRepositories.id, first.preparationId));
|
|
expect(await call("get_task_context", {})).toMatchObject({ completionContinuation: null });
|
|
expect(await call("select_workspace", { selection: { kind: "task_directory" }, expectedBindingRevision: view.bindingRevision, requestKey: "root-one" })).toMatchObject({ kind: "scheduled", applies: "next_normal_admission" });
|
|
const [task] = await server.db.select().from(issues).where(eq(issues.id, f.issueId));
|
|
expect(task).toMatchObject({ executionWorkspaceId: workspace.id, projectId: null, workspacePendingSelection: { requestKey: "root-one" } });
|
|
const runs = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
|
|
expect(runs).toHaveLength(1);
|
|
expect(runs[0]).toMatchObject({ id: f.runId, status: "running" });
|
|
});
|
|
it("allows a projectless task to use a directory also configured by an unrelated protected project", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const [project] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Protected physical source",
|
|
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
|
await server.db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected folder", cwd: f.workspace });
|
|
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
|
|
name: "Projectless alias", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory" }).returning();
|
|
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
|
|
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
|
const selection = { selection: { kind: "existing", workspaceId: workspace.id }, expectedBindingRevision: 0, requestKey: "protected-alias" };
|
|
const repository = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "protected-root-write" };
|
|
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: workspace.id, projectId: null } });
|
|
await expect(call("select_workspace", selection)).resolves.toMatchObject({ kind: "scheduled" });
|
|
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
|
|
});
|
|
|
|
it("denies new public clones for unlinked full-auto chat while keeping workspace discovery readable", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const cwd = await mkdtemp(join(dirname(f.workspace), "unlinked-task-"));
|
|
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
|
|
name: "Unlinked chat files", cwd, mode: "isolated_workspace", strategyType: "task_directory" }).returning();
|
|
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustPreset: "low_trust_review",
|
|
trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId } } };
|
|
await server.db.update(agents).set({ adapterConfig: { provider: "codex", codexPermissionMode: "never" } }).where(eq(agents.id, f.agentId));
|
|
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id,
|
|
executionPolicy: {} }).where(eq(issues.id, f.issueId));
|
|
await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
|
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: workspace.id } });
|
|
await expect(call("prepare_repository", { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "unlinked-full-auto" }))
|
|
.rejects.toThrow(/trusted task/);
|
|
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
|
|
});
|
|
|
|
it("does not expose repository preparation or workspace selection to Ask mode", async () => {
|
|
const f = await server.fixture({ mode: "ask", conversation: true, disableWakeOnDemand: true });
|
|
await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "denied" } })).rejects.toThrow();
|
|
await expect(f.authority.execute({ tool: "select_workspace", callId: randomUUID(), arguments: { selection: { kind: "task_directory" }, expectedBindingRevision: 0, requestKey: "denied" } })).rejects.toThrow();
|
|
});
|
|
it("lists shared and isolated task selections and excludes unavailable lifecycle states", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const definitions = [
|
|
{ name: "Shared active", mode: "shared_workspace", status: "active", closedAt: null },
|
|
{ name: "Shared idle", mode: "shared_workspace", status: "idle", closedAt: null },
|
|
{ name: "Isolated idle", mode: "isolated_workspace", status: "idle", closedAt: null },
|
|
{ name: "In review", mode: "isolated_workspace", status: "in_review", closedAt: null },
|
|
{ name: "Archived", mode: "shared_workspace", status: "archived", closedAt: null },
|
|
{ name: "Closed", mode: "shared_workspace", status: "active", closedAt: new Date() },
|
|
];
|
|
const rows = await server.db.insert(executionWorkspaces).values(definitions.map(definition => ({
|
|
...definition, companyId: f.companyId, projectId: null, strategyType: "task_directory", cwd: f.workspace,
|
|
}))).returning();
|
|
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
|
const listed = await call("list_workspaces", {}) as Array<{ id: string }>;
|
|
const candidates = new Set(rows.slice(0, 3).map(row => row.id));
|
|
expect(listed.filter(row => rows.some(candidate => candidate.id === row.id)).map(row => row.id).sort()).toEqual([...candidates].sort());
|
|
for (const row of rows) {
|
|
const view = await call("get_workspace", {}) as { bindingRevision: number };
|
|
const selection = call("select_workspace", { selection: { kind: "existing", workspaceId: row.id },
|
|
expectedBindingRevision: view.bindingRevision, requestKey: `select-${row.id}` });
|
|
if (candidates.has(row.id)) await expect(selection).resolves.toMatchObject({ kind: "scheduled" });
|
|
else await expect(selection).rejects.toThrow("Workspace is unavailable or inaccessible");
|
|
}
|
|
});
|
|
it("keeps source authorization on projectless task workspace inspection, selection, and preparation", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId));
|
|
const [source] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId,
|
|
projectId: f.projectId, projectWorkspaceId: f.projectWorkspaceId, mode: "shared_workspace", strategyType: "project_primary", cwd: f.workspace, name: "Source project" }).returning();
|
|
const [unrelated] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId,
|
|
projectId: null, sourceIssueId: f.blockerId, mode: "shared_workspace", strategyType: "task_directory", cwd: f.workspace, name: "Unrelated task files" }).returning();
|
|
await server.db.update(issues).set({ projectId: null }).where(eq(issues.id, f.blockerId));
|
|
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
|
|
const policy = (projectIds: string[]) => ({ trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { ...boundary, projectIds } } });
|
|
await server.db.update(agents).set({ permissions: policy([]) }).where(eq(agents.id, f.agentId));
|
|
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
|
await expect(call("list_workspaces", {})).rejects.toThrow(/outside.*boundary|company-wide|privileged/i);
|
|
for (const selection of [{ kind: "existing", workspaceId: source.id },
|
|
{ kind: "configured_source", projectWorkspaceId: f.projectWorkspaceId, mode: "shared" },
|
|
{ kind: "existing", workspaceId: unrelated.id }]) {
|
|
await expect(call("select_workspace", { selection, expectedBindingRevision: 0, requestKey: randomUUID() })).rejects.toThrow(/outside.*boundary/i);
|
|
}
|
|
await server.db.update(issues).set({ executionWorkspaceId: source.id }).where(eq(issues.id, f.issueId));
|
|
await expect(call("get_workspace", {})).rejects.toThrow(/outside.*boundary/i);
|
|
const repository = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "source-denied" };
|
|
await expect(call("prepare_repository", repository)).rejects.toThrow(/outside.*boundary/i);
|
|
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
|
|
await server.db.update(agents).set({ permissions: policy([f.projectId]) }).where(eq(agents.id, f.agentId));
|
|
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: source.id, projectId: f.projectId } });
|
|
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
|
|
await expect(call("prepare_repository", repository)).rejects.toThrow(/trusted task/);
|
|
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
|
|
expect((await server.db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, unrelated.id)))[0]?.cwd).toBe(f.workspace);
|
|
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.projectId).toBeNull();
|
|
});
|
|
});
|