mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 14:10:50 +02:00
## Thinking Path > - Paperclip manages AI agents and the tasks they perform. > - The native runner saves tool results and completion reports before it releases a session. > - Large project discovery responses can exceed the durable command limit. > - A shutdown failure can leave a saved answer waiting for workspace repair. > - Recovery reused the old assessment for a different status decision, which violated a database constraint. > - This pull request bounds discovery responses and lets recovery commit the saved result after workspace repair. > - The benefit is a task that reaches its correct final status without another provider turn. ## Linked Issues or Issue Description **What happened?** A native run can save its final answer, fail during shutdown, and leave the task In Progress after workspace repair succeeds. Reconciliation tries to reuse the failed-workspace assessment for a new decision. The one-decision-per-assessment constraint rejects the write. Replaying the old decision can also retain a fresh coordinator lease. Separately, retained session cleanup only recognizes the old `adapter_failed` label. The project-list tool returns full project records, including large descriptions and workspace configuration. A large response exceeds the runner's durable command limit. The settlement diagnostic previously recorded only a failure flag. **Expected behavior** Project discovery stays within the command limit. Recovery finishes the saved result after workspace repair, releases its lease, and preserves the original error for inspection. It does not repeat provider work or relax session ownership checks. **Steps to reproduce** 1. Return large project records from `list_projects` and observe an oversized semantic result. 2. Persist an accepted native completion result, then record a shutdown failure. 3. Finalize with a failed workspace, repeat that attempt, then record successful workspace repair. 4. Reconcile the run. Before this fix, the issue stays In Progress. **Paperclip version or commit** Reproduced on `a386a599983519eb1d399f8b770bfccdb2a74762`. **Deployment mode** Self-hosted server with Paperclip Runner. Related transport work: #12208 drains queued events; #12241 resumes interrupted semantic calls. This change addresses bounded project discovery and accepted-result finalization. ## What Changed - Read bounded project summary projections from the database and return at most 50 authorized summaries with a continuation cursor and explicit description truncation. Agent and run trust boundaries narrow the database candidates; project-specific policies still receive full authorization. Only visible projects determine continuations. The default project-list API remains unchanged. - Record bounded, content-free settlement failure causes for command limits, storage errors, and rejected dispatch. - Include workspace state in assessment identity. Preserve the initial assessment for interrupted finalization, and commit replacement assessment and decision references together. - Release the coordinator lease when an existing decision is replayed, without repeating its effects. - Clear stale errors when recovery succeeds and retain them in `recoveredExecutionFailure`. - Accept both current and legacy close-failure labels in the existing exact-state cleanup path. - Add regression tests and update the tool contracts and recovery documentation. ## Verification - Red: the new project paging, settlement diagnostic, current cleanup label, and repaired-workspace regressions failed on the original implementation. - Green: protocol/catalog/tool checks (117 tests), the full project-tool and finalizer suites (47 tests), cleanup ownership cases (70 tests), and cleanup sweep cases (4 tests) pass. Database-backed pagination covers large descriptions/configuration, complete enumeration, uppercase cursors, agent/run restrictions, project-policy scope contributions, and identical results/cursors when hidden projects are added. - The initial CI failures in interrupted Board waits, contended endpoint proof, and semantic schema validation were reproduced and fixed; all affected cases pass locally. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - `pnpm test:run` — started before the review corrections; it spanned several source revisions and was stopped after reporting old-behavior and timing failures. It is not claimed green. Fresh project and recovery suites pass; the recovery suite also passes all 25 cases with the broad runner’s isolated home/config. The Slack timing case passed in isolation. Latest-head CI is the authoritative complete test matrix. - Existing authorization suite — 66 tests passed. - Latest-head CI on `8e5763d915aa6f375bdab6601996899ea01496fc` — 55 checks passed, 4 intentionally skipped, no pending or failing checks. - Greptile — 5/5, zero unresolved threads on the same commit. - `git diff --check` — passed. ## Risks - `list_projects` now returns summaries. Callers must follow `nextCursor` and use the authorized project API for full records. Candidate narrowing is only an optimization: project policy and responsible-user authorization remain authoritative. - Assessment identity changes for workspace finalization. Existing evidence remains intact; no migration is needed. - Cleanup still requires matching identities, settled tool evidence, and verified process ownership. Unknown tool outcomes remain blocked from session reuse. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code execution, and GitHub tools. The exact serving model ID and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
386 lines
30 KiB
TypeScript
386 lines
30 KiB
TypeScript
import { projectService } from "../services/projects.js";
|
|
import { callProjectTool } from "../services/project-tools.js";
|
|
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, issues, heartbeatRuns, projects, companyMemberships, executionWorkspaces } from "@paperclipai/db";
|
|
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
|
|
import { issueService } from "../services/issues.js";
|
|
import { documentService } from "../services/documents.js";
|
|
import { activityService } from "../services/activity.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("chat project tool handoff", () => {
|
|
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; });
|
|
const call = (fixture: Awaited<ReturnType<typeof server.fixture>>, tool: string, args: Record<string, unknown>) => fixture.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
|
|
|
it("pages only visible projects and prefilters low-trust agent and run scopes", async () => {
|
|
const f = await server.fixture({ disableWakeOnDemand: true });
|
|
const ids = Array.from({ length: 53 }, (_, i) => `abcdefab-0000-4000-8000-${String(i).padStart(12, "0")}`);
|
|
await server.db.insert(projects).values(ids.map((id, i) => ({
|
|
id, companyId: f.companyId, name: `Project ${i}`, status: "in_progress",
|
|
description: "日本語🦀".repeat(5_000), executionWorkspacePolicy: { oversized: "x".repeat(20_000) },
|
|
})));
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
const toolInput = { name: "list_projects", apiUrl: server.apiUrl, token, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: false };
|
|
const first = await callProjectTool({ ...toolInput, arguments: {} });
|
|
expect(first.projects).toHaveLength(50);
|
|
expect(Buffer.byteLength(JSON.stringify(first))).toBeLessThan(256 * 1024);
|
|
expect(first.projects.find((p: { id: string }) => p.id === ids[0])).toMatchObject({ descriptionTruncated: true });
|
|
expect(first.projects.every((p: object) => !Object.hasOwn(p, "executionWorkspacePolicy") && !Object.hasOwn(p, "workspaces"))).toBe(true);
|
|
const last = await callProjectTool({ ...toolInput, arguments: { cursor: first.nextCursor } });
|
|
expect([...first.projects, ...last.projects].map((p: { id: string }) => p.id).sort()).toEqual([...ids, f.projectId].sort());
|
|
expect(last.nextCursor).toBeNull();
|
|
// A permitted project beyond the first database batch must remain discoverable.
|
|
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: ids.slice(-2) } } };
|
|
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
|
|
expect((await projectService(server.db).listSummaries(f.companyId, {
|
|
limit: 51, includeArchived: false, candidateIds: ids.slice(-2),
|
|
})).map(p => p.id)).toEqual(ids.slice(-2));
|
|
const restricted = await callProjectTool({ ...toolInput, arguments: { limit: 1 } });
|
|
expect(restricted.projects.map((p: { id: string }) => p.id)).toEqual([ids[51]]);
|
|
expect(restricted.nextCursor).toBe(ids[51]);
|
|
const restrictedLast = await callProjectTool({ ...toolInput, arguments: { limit: 1, cursor: restricted.nextCursor } });
|
|
expect(restrictedLast.projects.map((p: { id: string }) => p.id)).toEqual([ids[52]]);
|
|
expect(restrictedLast.nextCursor).toBeNull();
|
|
// A run-only boundary also restricts the database candidates.
|
|
await server.db.update(agents).set({ permissions: {} }).where(eq(agents.id, f.agentId));
|
|
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
await server.db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot, executionPolicy: policy } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const runRestricted = await callProjectTool({ ...toolInput, arguments: {} });
|
|
expect(runRestricted.projects.map((p: { id: string }) => p.id)).toEqual(ids.slice(-2));
|
|
expect(runRestricted.nextCursor).toBeNull();
|
|
// Adding denied projects must not change either the visible page or cursor.
|
|
await server.db.insert(projects).values({ companyId: f.companyId, name: "Hidden" });
|
|
expect(await callProjectTool({ ...toolInput, arguments: {} })).toEqual(runRestricted);
|
|
// Project policy may contribute a root scope, so candidate narrowing must
|
|
// not suppress a project that the full authorization decision permits.
|
|
await server.db.update(projects).set({ executionWorkspacePolicy: {
|
|
authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId } },
|
|
} }).where(eq(projects.id, f.projectId));
|
|
const withProjectScope = await callProjectTool({ ...toolInput, arguments: {} });
|
|
expect(withProjectScope.projects.map((p: { id: string }) => p.id).sort()).toEqual([...ids.slice(-2), f.projectId].sort());
|
|
expect(withProjectScope.nextCursor).toBeNull();
|
|
});
|
|
|
|
it("allows a conversation reply to enter review without manufacturing a review interaction", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}`, {
|
|
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ status: "in_review", comment: "The plan is ready for our next discussion." }),
|
|
});
|
|
const result = await response.json();
|
|
expect(response.status, JSON.stringify(result)).toBe(200);
|
|
expect(result.status).toBe("in_review");
|
|
});
|
|
|
|
it("creates an ordinary project task and its plan atomically, retaining the conversation plan", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", body: "Full discussion plan" });
|
|
const input = { title: "Implement the clarified outcome", projectId: f.projectId, initialPlan: "# Execution plan\n\nBuild and verify the outcome.", idempotencyKey: "handoff" };
|
|
const first = await call(f, "create_task", input) as any;
|
|
const again = await call(f, "create_task", input) as any;
|
|
expect(again.task.id).toBe(first.task.id);
|
|
expect(first.task.parentId).toBeNull();
|
|
const [task] = await server.db.select().from(issues).where(eq(issues.id, first.task.id));
|
|
expect(task).toMatchObject({ projectId: f.projectId, assigneeAgentId: f.agentId, status: "todo" });
|
|
expect((await documentService(server.db).getIssueDocumentByKey(task.id, "plan"))?.body).toBe(input.initialPlan);
|
|
expect((await documentService(server.db).getIssueDocumentByKey(f.issueId, "plan"))?.body).toBe("Full discussion plan");
|
|
await expect(call(f, "create_task", { ...input, title: "Different" })).rejects.toThrow(/idempotency/);
|
|
});
|
|
|
|
it("rejects creation, child helpers, and reparenting under a chat, while retaining legacy children", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
const svc = issueService(server.db);
|
|
await expect(svc.create(f.companyId, { title: "Invalid", parentId: f.issueId })).rejects.toThrow(/cannot have new subtasks/);
|
|
await expect(svc.createChild(f.issueId, { title: "Invalid" })).rejects.toThrow(/cannot have new subtasks/);
|
|
await expect(svc.importIssues(f.companyId, [{
|
|
id: randomUUID(), ref: "imported", title: "Imported child", parentId: f.issueId,
|
|
projectId: null, projectWorkspaceId: null, description: null, assigneeAgentId: null,
|
|
status: "backlog", priority: "medium", billingCode: null, assigneeAdapterOverrides: null,
|
|
executionWorkspaceSettings: null, labelIds: [], monitorNotes: null, monitorScheduledBy: null,
|
|
}])).rejects.toThrow(/cannot have new subtasks/);
|
|
const ordinary = await svc.create(f.companyId, { title: "Ordinary" });
|
|
await expect(svc.update(ordinary.id, { parentId: f.issueId })).rejects.toThrow(/cannot have new subtasks/);
|
|
await server.db.update(issues).set({ parentId: f.issueId }).where(eq(issues.id, ordinary.id));
|
|
expect(await svc.update(ordinary.id, { title: "Legacy edited", parentId: f.issueId })).toMatchObject({ title: "Legacy edited" });
|
|
expect(await svc.update(ordinary.id, { parentId: null })).toMatchObject({ parentId: null });
|
|
});
|
|
|
|
it("hands off through the same API used by Claude/Codex MCP with the plan present on return", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
const result = await callProjectTool({ name: "create_task", arguments: { title: "MCP handoff", projectId: f.projectId, initialPlan: "# Plan\nImplement in the execution task.", idempotencyKey: "mcp" },
|
|
apiUrl: server.apiUrl, token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!,
|
|
companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true });
|
|
expect(result).toMatchObject({ parentId: null, projectId: f.projectId, assigneeAgentId: f.agentId });
|
|
expect((await documentService(server.db).getIssueDocumentByKey(result.id, "plan"))?.body).toContain("Implement in the execution task");
|
|
});
|
|
|
|
it("allows low-trust chat handoffs and self-assigned subtasks through both creation endpoints", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
|
|
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
const task = await callProjectTool({ name: "create_task", arguments: { title: "Contained MCP handoff", projectId: f.projectId, initialPlan: "Do the requested work", idempotencyKey: "contained-mcp" },
|
|
apiUrl: server.apiUrl, token, companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true });
|
|
expect(task).toMatchObject({ parentId: null, projectId: f.projectId, assigneeAgentId: f.agentId, sourceTrust: { disposition: "quarantined" } });
|
|
expect(task.executionPolicy.authorizationPolicy).toMatchObject({ trustPreset: "low_trust_review", ...policy.authorizationPolicy });
|
|
expect((await documentService(server.db).getIssueDocumentByKey(task.id, "plan"))?.sourceTrust).toMatchObject({ disposition: "quarantined" });
|
|
const native = await call(f, "create_task", { title: "Contained native handoff", projectId: f.projectId, idempotencyKey: "contained-native" }) as any;
|
|
expect(native.task).toMatchObject({ parentId: null, projectId: f.projectId, assigneeActorId: f.agentId });
|
|
const [nativeTask] = await server.db.select().from(issues).where(eq(issues.id, native.task.id));
|
|
expect(nativeTask.executionPolicy).toMatchObject({ authorizationPolicy: { trustPreset: "low_trust_review", ...policy.authorizationPolicy } });
|
|
expect(nativeTask.sourceTrust).toMatchObject({ disposition: "quarantined" });
|
|
|
|
for (const path of [`/api/issues/${task.id}/children`, `/api/companies/${f.companyId}/issues`]) {
|
|
const response = await fetch(`${server.apiUrl}${path}`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: `Self decomposition ${path}`, parentId: task.id, assigneeAgentId: f.agentId, status: "backlog" }),
|
|
});
|
|
const child = await response.json();
|
|
expect(response.status, JSON.stringify(child)).toBe(201);
|
|
expect(child).toMatchObject({ parentId: task.id, projectId: f.projectId, assigneeAgentId: f.agentId, sourceTrust: { disposition: "quarantined" } });
|
|
expect(child.executionPolicy.authorizationPolicy).toMatchObject({ trustPreset: "low_trust_review", ...policy.authorizationPolicy });
|
|
}
|
|
});
|
|
|
|
it("retains a run-only low-trust boundary on a new task even when standard policy is requested", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const executionPolicy = { authorizationPolicy: { trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
|
|
const [run] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
|
|
await server.db.update(heartbeatRuns).set({ contextSnapshot: { ...run.contextSnapshot, executionPolicy } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: "Retain containment", projectId: f.projectId, assigneeAgentId: f.agentId, executionPolicy: { authorizationPolicy: { trustPreset: "standard" } }, status: "backlog" }),
|
|
});
|
|
const task = await response.json();
|
|
expect(response.status, JSON.stringify(task)).toBe(201);
|
|
expect(task.executionPolicy).toMatchObject(executionPolicy);
|
|
});
|
|
|
|
it("enforces the proposed task scope and assignee before creating any low-trust work", async () => {
|
|
const f = await server.fixture({ disableWakeOnDemand: true });
|
|
const policy = { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", companyId: f.companyId, projectIds: [f.projectId] } } };
|
|
await server.db.update(agents).set({ permissions: policy }).where(eq(agents.id, f.agentId));
|
|
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Outside boundary" }).returning();
|
|
const [otherAgent] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Unauthorized assignee", status: "active" }).returning();
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
const create = async (path: string, fields: Record<string, unknown>) => {
|
|
const response = await fetch(`${server.apiUrl}${path}`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: `Boundary test ${randomUUID()}`, status: "backlog", assigneeAgentId: f.agentId, ...fields }),
|
|
});
|
|
return { status: response.status, body: await response.json() };
|
|
};
|
|
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
|
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
|
|
for (const fields of [
|
|
{ projectId: outside.id },
|
|
{ projectId: outside.id, assigneeAgentId: null },
|
|
{ projectId: f.foreignProjectId },
|
|
{ projectId: f.projectId, assigneeAgentId: otherAgent.id },
|
|
{ projectId: f.projectId, assigneeAgentId: null, assigneeUserId: "board-user" },
|
|
]) {
|
|
const result = await create(path, fields);
|
|
expect(result.status, JSON.stringify(result.body)).toBe(403);
|
|
expect(result.body.error).toMatch(/outside.*boundary|cannot assign work to board users|different company/i);
|
|
}
|
|
}
|
|
const after = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
|
expect(after).toEqual(before);
|
|
for (const fields of [{ projectId: outside.id }, { projectId: f.projectId, assigneeActorId: otherAgent.id }]) {
|
|
await expect(call(f, "create_task", { title: "Native scope denial", idempotencyKey: randomUUID(), ...fields })).rejects.toThrow(/outside.*boundary/);
|
|
}
|
|
|
|
await server.db.update(agents).set({ permissions: { ...policy, authorizationPolicy: {
|
|
...policy.authorizationPolicy, assignmentPolicy: { mode: "protected" }, protectedAgent: { blockAssignment: true },
|
|
} } }).where(eq(agents.id, f.agentId));
|
|
const protectedResult = await create(`/api/companies/${f.companyId}/issues`, { projectId: f.projectId });
|
|
expect(protectedResult.status, JSON.stringify(protectedResult.body)).toBe(403);
|
|
expect(protectedResult.body.error).toContain("protected-agent policy");
|
|
});
|
|
|
|
it("allows low-trust root-scoped subtasks without granting descendants of an exact issue scope", async () => {
|
|
const f = await server.fixture({ disableWakeOnDemand: true });
|
|
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
|
|
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: boundary } } }).where(eq(agents.id, f.agentId));
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
|
|
const response = await fetch(`${server.apiUrl}${path}`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: `Root subtask ${randomUUID()}`, parentId: f.issueId, assigneeAgentId: f.agentId, status: "backlog" }),
|
|
});
|
|
const child = await response.json();
|
|
expect(response.status, JSON.stringify(child)).toBe(201);
|
|
expect(child.executionPolicy.authorizationPolicy.trustBoundary).toMatchObject(boundary);
|
|
}
|
|
await expect(call(f, "create_task", { title: "Native root subtask", idempotencyKey: "root-child", status: "backlog" })).resolves.toMatchObject({ task: { parentId: f.issueId, assigneeActorId: f.agentId } });
|
|
|
|
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Unrelated project" }).returning();
|
|
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
|
for (const path of [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`]) {
|
|
const response = await fetch(`${server.apiUrl}${path}`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: "Root cannot escape into another project", parentId: f.issueId,
|
|
projectId: outside.id, assigneeAgentId: f.agentId, status: "backlog" }),
|
|
});
|
|
expect(response.status, JSON.stringify(await response.json())).toBe(403);
|
|
}
|
|
await expect(call(f, "create_task", { title: "Native root cannot escape", projectId: outside.id,
|
|
idempotencyKey: "root-outside", status: "backlog" })).rejects.toThrow(/outside.*boundary/);
|
|
expect(await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
|
|
|
|
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", issueIds: [f.issueId] } } } }).where(eq(agents.id, f.agentId));
|
|
await expect(call(f, "create_task", { title: "Exact scope is not a subtree", idempotencyKey: "exact-child" })).rejects.toThrow(/outside.*boundary/);
|
|
});
|
|
|
|
it("authorizes workspace-derived projects before creating children of a projectless root", async () => {
|
|
const f = await server.fixture({ disableWakeOnDemand: true });
|
|
const settings = instanceSettingsService(server.db);
|
|
const previous = await settings.getExperimental();
|
|
await settings.updateExperimental({ enableIsolatedWorkspaces: true });
|
|
try {
|
|
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId));
|
|
const [executionWorkspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId,
|
|
projectId: f.projectId, projectWorkspaceId: f.projectWorkspaceId, mode: "shared", strategyType: "project_primary", name: "Outside root" }).returning();
|
|
const boundary = { mode: "low_trust_review", companyId: f.companyId, rootIssueId: f.issueId };
|
|
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: boundary } } }).where(eq(agents.id, f.agentId));
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
const before = await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId));
|
|
const paths = [`/api/companies/${f.companyId}/issues`, `/api/issues/${f.issueId}/children`];
|
|
const selections = [{ projectWorkspaceId: f.projectWorkspaceId }, { executionWorkspaceId: executionWorkspace.id },
|
|
{ inheritExecutionWorkspaceFromIssueId: f.blockerId }];
|
|
const create = async (path: string, selection: Record<string, string>) => {
|
|
const response = await fetch(`${server.apiUrl}${path}`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ title: `Workspace scope ${randomUUID()}`, parentId: f.issueId,
|
|
assigneeAgentId: f.agentId, status: "backlog", ...selection }),
|
|
});
|
|
return { status: response.status, body: await response.json() };
|
|
};
|
|
for (const path of paths) {
|
|
for (const selection of selections) {
|
|
if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue;
|
|
const response = await create(path, selection);
|
|
expect(response.status, JSON.stringify(response.body)).toBe(403);
|
|
}
|
|
}
|
|
expect(await server.db.select({ id: issues.id }).from(issues).where(eq(issues.companyId, f.companyId))).toEqual(before);
|
|
|
|
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
|
|
trustBoundary: { ...boundary, projectIds: [f.projectId] },
|
|
} } }).where(eq(agents.id, f.agentId));
|
|
for (const path of paths) {
|
|
for (const selection of selections) {
|
|
if (path.endsWith("/children") && selection.inheritExecutionWorkspaceFromIssueId) continue;
|
|
const response = await create(path, selection);
|
|
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
|
expect(response.body.projectId).toBe(f.projectId);
|
|
expect(response.body.executionPolicy.authorizationPolicy.trustBoundary.projectIds).toEqual([f.projectId]);
|
|
}
|
|
}
|
|
} finally {
|
|
await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces });
|
|
}
|
|
});
|
|
|
|
it("keeps the responsible user's assignment restrictions on low-trust self-assignment", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { mode: "low_trust_review", projectIds: [f.projectId] } } } }).where(eq(agents.id, f.agentId));
|
|
await server.db.update(companyMemberships).set({ membershipRole: "viewer" }).where(eq(companyMemberships.principalId, f.responsibleUserId!));
|
|
await expect(call(f, "create_task", { title: "Viewer cannot assign", projectId: f.projectId, idempotencyKey: "viewer-denied" })).rejects.toThrow();
|
|
expect(await server.db.select().from(issues).where(eq(issues.companyId, f.companyId))).toHaveLength(2);
|
|
});
|
|
|
|
it("does not reuse an out-of-scope task through a title or idempotency collision", async () => {
|
|
const f = await server.fixture({ disableWakeOnDemand: true });
|
|
const [outside] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Private project" }).returning();
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
for (const parentId of [null, f.issueId]) {
|
|
const key = randomUUID();
|
|
const title = `Existing private work ${randomUUID()}`;
|
|
const existing = await issueService(server.db).create(f.companyId, { projectId: outside.id, parentId,
|
|
title, description: "Private contents", assigneeAgentId: f.agentId, status: "backlog", idempotencyKey: key });
|
|
for (const allowed of [false, true]) {
|
|
await server.db.update(agents).set({ permissions: { trustPreset: "low_trust_review", authorizationPolicy: {
|
|
trustBoundary: { mode: "low_trust_review", projectIds: [f.projectId, ...(allowed ? [outside.id] : [])] },
|
|
} } }).where(eq(agents.id, f.agentId));
|
|
for (const collision of [{ idempotencyKey: key, title: "Different proposed task" }, { title }]) {
|
|
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/issues`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ projectId: f.projectId, parentId, assigneeAgentId: f.agentId, status: "backlog", ...collision }),
|
|
});
|
|
const body = await response.json();
|
|
expect(response.status, JSON.stringify(body)).toBe(allowed ? 200 : 403);
|
|
if (allowed) expect(body).toMatchObject({ id: existing.id, projectId: outside.id, deduplicated: true });
|
|
else expect(JSON.stringify(body)).not.toContain("Private contents");
|
|
}
|
|
}
|
|
expect((await issueService(server.db).getById(existing.id))?.projectId).toBe(outside.id);
|
|
}
|
|
});
|
|
|
|
it("retains ordinary child delegation and projectless task creation", async () => {
|
|
const f = await server.fixture();
|
|
const result = await call(f, "create_task", { title: "Delegate ordinary work", idempotencyKey: "child" }) as any;
|
|
expect(result.task.parentId).toBe(f.issueId);
|
|
expect(await issueService(server.db).create(f.companyId, { title: "No project needed" })).toMatchObject({ projectId: null });
|
|
});
|
|
|
|
it("creates a project once through the production API and records it on the source feed", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
const input = { name: "New non-code project", description: "A well-scoped outcome", idempotencyKey: "project" };
|
|
const results = await Promise.all(Array.from({ length: 4 }, () => call(f, "create_project", input))) as any[];
|
|
const project = results[0];
|
|
expect(new Set(results.map(result => result.id)).size).toBe(1);
|
|
expect(project.id).toBeTruthy();
|
|
expect((await call(f, "create_project", input) as any).id).toBe(project.id);
|
|
const feed = await activityService(server.db).forIssue(f.issueId);
|
|
expect(feed.filter(event => event.action === "project.created")).toHaveLength(1);
|
|
expect(feed.find(event => event.action === "project.created")).toMatchObject({ entityId: project.id, runId: f.runId, details: { sourceIssueId: f.issueId } });
|
|
await expect(call(f, "create_project", { ...input, name: "Changed" })).rejects.toThrow(/different inputs/);
|
|
});
|
|
|
|
it("includes an explicit workspace repository in the committed project card", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
const project = await call(f, "create_project", { name: "Workspace repo", workspace: { repoUrl: "https://github.com/example/web" }, idempotencyKey: "workspace" }) as any;
|
|
const feed = await activityService(server.db).forIssue(f.issueId);
|
|
expect(feed.find(event => event.entityId === project.id)?.details?.repositories).toEqual([
|
|
expect.objectContaining({ url: "https://github.com/example/web" }),
|
|
]);
|
|
});
|
|
|
|
it("registers multiple previously unknown GitHub URLs and deduplicates equivalent URLs", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
const project = await call(f, "create_project", { name: "Across repos", repositoryUrls: ["https://github.com/example/web.git", "https://github.com/example/api", "https://github.com/example/web/"], idempotencyKey: "urls" }) as any;
|
|
expect(project.workspaces.map((w: any) => w.repoUrl).sort()).toEqual(["https://github.com/example/api", "https://github.com/example/web"]);
|
|
expect(project.workspaces.filter((w: any) => w.isPrimary)).toHaveLength(1);
|
|
await expect(call(f, "create_project", { name: "Invalid", repositoryUrls: ["https://github.com/example/api"], workspace: { repoUrl: "https://github.com/example/web" }, idempotencyKey: "conflict" })).rejects.toThrow(/either workspace/);
|
|
await expect(call(f, "create_project", { name: "Invalid", repositoryUrls: ["https://user:password@github.com/example/api"], idempotencyKey: "credentials" })).rejects.toThrow(/without credentials/);
|
|
});
|
|
|
|
it("allows planning documents while denying project/task creation in Plan and Ask mode", async () => {
|
|
for (const mode of ["planning", "ask"] as const) {
|
|
const f = await server.fixture({ conversation: true, mode });
|
|
await expect(call(f, "create_project", { name: "No", idempotencyKey: "no" })).rejects.toThrow(/mode_denied/);
|
|
await expect(call(f, "create_task", { title: "No", idempotencyKey: "no" })).rejects.toThrow(/mode_denied/);
|
|
if (mode === "planning") await call(f, "write_document", { key: "plan", title: "Plan", body: "Clarify and plan here", idempotencyKey: "plan" });
|
|
}
|
|
});
|
|
|
|
it("rejects invented repository IDs and cancelled runs without creating a project", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
await expect(call(f, "create_project", { name: "Missing repo", repositoryIds: ["999999"], idempotencyKey: "missing" })).rejects.toThrow(/repository.*available/);
|
|
await server.db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
|
|
await expect(call(f, "create_project", { name: "Cancelled", idempotencyKey: "cancelled" })).rejects.toThrow();
|
|
});
|
|
});
|