mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents split work into tasks. A task can have a parent task. > - Today agents can nest tasks to any depth: parent, child, grandchild, and deeper. > - Deep agent-made trees are hard to follow, and an agent that works on a child task makes grandchildren by default. > - This pull request limits agent-created trees to two levels: a top-level task and its children. > - A request for a grandchild does not fail. The server puts the new task beside the requester and tells the agent. > - Board users keep full control. They can still nest to any depth. Existing deep trees stay valid. > - The pull request also records which task created each new task, so the audit trail stays complete when the parent changes. ## Linked Issues or Issue Description No public issue exists. This is the feature description. Related: #16853 (budgets that include descendant work) also reasons about task trees; this change does not alter its traversal. **Subsystem affected** Issues (tasks): create, child create and move in the issue service and routes. Agent tools, chat suggestions and creation provenance use these paths. **Problem or motivation** Agents create subtasks as deep as they want. An agent that runs on a child task gets that child as the default parent, so its follow-ups become grandchildren. The trees become deep and hard to read. The parent link is also the only record of which task created a follow-up, so that record is lost when the parent changes. **Proposed solution** Limit agent-created trees to a parent and its children. When an agent asks for a grandchild, put the new task under the requester's parent and return `parentAdjusted`. Do not reject the request. Keep board users unrestricted. Record the originating task in a new server-set `origin_issue_id` column. Keep `origin_run_id` as the originating run. **Alternatives considered** Reject grandchild requests with an error. We did not do this, because agents then fail mid-task. Add a database constraint. We did not do this, because existing deep trees must stay valid and nesting can return later. ## What Changed - Add `MAX_AGENT_TASK_HIERARCHY_DEPTH` (2) and the `IssueParentAdjustment` type in `packages/shared`. - Add `resolveNewTaskParent` and `planIssueParentChange` in `server/src/services/issues.ts`. `create`, `createChild` and `update` use the agent limit by default (`parentDepthPolicy: "flatten"`). - The issue create, `/children` and PATCH routes pass `allow_nested` for board users. For agents, the routes check authorization against the parent that the server actually writes. - The requester keeps its behavior when the server moves a task beside it: `blockParentUntilDone` blocks the requester, and the new task inherits the requester's workspace and privacy. - The server never moves a task above a low-trust boundary root that contains the requester. - These subsystems keep any depth, because they find their work by parent: watchdog reviews, monitor recovery, email-channel placement, and routines that a person configured. - An agent move of a task that has children skips only the parent change and returns `reason: "has_children"`. - Add `issues.origin_issue_id` (nullable, `on delete set null`, indexed) in migration `0434`. Only the server sets it. The `createdFrom` detail field, the `createdFromIssueId` filter and task-creation provenance read it first. - Chat prompts now let a chat agent create one task, a task with children, or separate tasks. The conversation is never a parent. - The runner `create_task` tool accepts an optional `parentTaskId`. A chat agent can create a top-level task and then put subtasks under it. The run must be able to read and change the parent. A conversation is never a parent. The depth limit still applies. Project tools map the field to `parentId` through the issue route. The protocol manifest, semantic contracts and eval fixture are regenerated. - Watchdog runs that have an active watchdog keep their requested parent on all create paths, because their access is limited to the watched subtree. - Fix a bug that the test drive found: the chat (MCP) `create_task` ignored the requested `status`, so a `backlog` task became `todo` and started to run. It now uses the same rule as the native tool. - Update `skills/paperclip`, `doc/SPEC-implementation.md`, and add the plan `doc/plans/2026-11-05-two-level-task-hierarchy.md`. ## Verification - `cd server && npx vitest run src/__tests__/agent-task-hierarchy-depth.test.ts src/__tests__/agent-task-hierarchy-depth-routes.test.ts`: 19 new tests for service and route behavior, all pass. - Related suites (64 files: issues service, thread interactions, privacy, watchdogs, provenance, runner tool authority, decisions, routines, project tools): 1208 tests pass. Two tests changed because of the intended behavior and pass now. Two `email-channels.integration` tests fail locally because the test cannot find a bundled skill file from this worktree path. This change does not touch that code. - `packages/db`: migration safety, snapshot drift and journal tests pass. - Typecheck passes for `server`, `packages/shared`, `packages/db`, `cli` and `ui` (local `ui` errors come only from generated Storybook catalog files that are absent). - Local test drive on an isolated instance with real Claude agents: 29 of 29 use cases pass, including an agent on a child task (follow-ups placed beside it, with the notice) and a chat that creates a top-level task with three subtasks. - I did not run the full `pnpm test:run` or `pnpm build` locally. CI runs them. ## Risks - Behavior change for agents: some follow-ups that were grandchildren are now siblings. The parent of the requester is woken when all its children finish, which now includes these tasks. - The runner `create_task` input contract adds one optional field, so the protocol manifest and the pack contract change. The tool output does not change: it shows the actual `parentId`, and the activity log records `parentAdjusted`. - Migration `0434` adds a nullable column, a foreign key and an index. It does not backfill rows. - `IssueCreationSource.run` is now nullable. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edits, local test runs). ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
504 lines
39 KiB
TypeScript
504 lines
39 KiB
TypeScript
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
|
|
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, vi } 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 { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
|
|
|
|
(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.each([true, false])("defaults raw API creation only for a chat-bound run: %s", async (conversation) => {
|
|
const f = await server.fixture({ conversation, disableWakeOnDemand: true });
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
for (const assignment of [{}, { assigneeAgentId: null }]) {
|
|
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: `Requested task ${randomUUID()}`, projectId: f.projectId, status: "todo", ...assignment }),
|
|
});
|
|
const result = await response.json();
|
|
expect(response.status, JSON.stringify(result)).toBe(201);
|
|
expect(result).toMatchObject({ assigneeAgentId: conversation ? f.agentId : null, assigneeUserId: null });
|
|
}
|
|
});
|
|
|
|
it.each(["native", "mcp"])("preserves an explicit alternate agent or person through %s creation", async (transport) => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const [otherAgent] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Requested editor", status: "idle", adapterType: "process" }).returning();
|
|
for (const assignment of [{ assigneeActorId: otherAgent.id }, { assigneeUserId: f.responsibleUserId }]) {
|
|
const args = { title: `Explicitly assigned task ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), ...assignment };
|
|
const result = transport === "native" ? await call(f, "create_task", args) as any : await callProjectTool({
|
|
name: "create_task", arguments: args, 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,
|
|
});
|
|
const taskId = transport === "native" ? result.task.id : result.id;
|
|
const [task] = await server.db.select().from(issues).where(eq(issues.id, taskId));
|
|
expect(task).toMatchObject({ assigneeAgentId: assignment.assigneeActorId ?? null, assigneeUserId: assignment.assigneeUserId ?? null });
|
|
}
|
|
});
|
|
|
|
it.each(["native", "mcp"])("honors backlog status and parentTaskId through %s creation", async (transport) => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const create = async (args: Record<string, unknown>) => {
|
|
const result = transport === "native" ? await call(f, "create_task", args) as any : await callProjectTool({
|
|
name: "create_task", arguments: args, 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,
|
|
});
|
|
const [task] = await server.db.select().from(issues).where(eq(issues.id, transport === "native" ? result.task.id : result.id));
|
|
return task;
|
|
};
|
|
const parent = await create({ title: `Backlog parent ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), status: "backlog" });
|
|
expect(parent).toMatchObject({ status: "backlog", parentId: null });
|
|
const child = await create({ title: `Backlog child ${randomUUID()}`, projectId: f.projectId, idempotencyKey: randomUUID(), status: "backlog", parentTaskId: parent.id });
|
|
expect(child).toMatchObject({ status: "backlog", parentId: parent.id });
|
|
});
|
|
|
|
it("persists required output review supplied through MCP creation", async () => {
|
|
const f = await server.fixture({ conversation: true });
|
|
const requirement = { version: 1, deliverables: [{ key: "copy", label: "Launch copy" }] };
|
|
const result = await callProjectTool({ name: "create_task", arguments: {
|
|
title: "MCP review gate", projectId: f.projectId, idempotencyKey: "mcp-quality-gate", requiredQualityReview: requirement,
|
|
}, 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 }) as any;
|
|
const [task] = await server.db.select().from(issues).where(eq(issues.id, result.id));
|
|
expect(task.executionPolicy).toMatchObject({ requiredQualityReview: requirement });
|
|
});
|
|
|
|
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);
|
|
const hiddenProject = fields.projectId !== f.projectId && path.startsWith("/api/companies/");
|
|
expect(result.status, JSON.stringify(result.body)).toBe(hiddenProject ? 404 : 403);
|
|
expect(result.body.error).toMatch(hiddenProject ? /^Project not found$/ : /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" }),
|
|
});
|
|
const denied = await response.json();
|
|
const hiddenProject = path.startsWith("/api/companies/");
|
|
expect(response.status, JSON.stringify(denied)).toBe(hiddenProject ? 404 : 403);
|
|
expect(denied.error).toMatch(hiddenProject ? /^Project not found$/ : /outside.*boundary/i);
|
|
}
|
|
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 sources without assigning projects to 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_workspace", 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: Array<Record<string, string>> = [{ 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(issues).set({ executionWorkspaceId: executionWorkspace.id }).where(eq(issues.id, f.issueId));
|
|
await expect(call(f, "create_task", { title: "Inherited source cannot escape", idempotencyKey: "inherited-source-denied", status: "backlog" })).rejects.toThrow(/outside.*boundary/i);
|
|
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(selection.inheritExecutionWorkspaceFromIssueId ? f.projectId : null);
|
|
if (selection.projectWorkspaceId) {
|
|
expect(response.body.projectWorkspaceId).toBe(selection.projectWorkspaceId);
|
|
expect(response.body.executionWorkspaceId).toBeNull();
|
|
}
|
|
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("recovers a committed project after losing its HTTP response without repeating the effect", async () => {
|
|
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
|
const request = { tool: "create_project", arguments: { name: "Surviving project", idempotencyKey: "lost-response" }, callId: randomUUID() };
|
|
const realFetch = globalThis.fetch;
|
|
const lostResponse = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => {
|
|
const response = await realFetch(url, options);
|
|
if (response.status === 201 && String(url).endsWith(`/companies/${f.companyId}/projects`)) {
|
|
await response.arrayBuffer();
|
|
throw new Error("Controller lost the committed response");
|
|
}
|
|
return response;
|
|
});
|
|
try {
|
|
await expect(f.authority.execute(request)).rejects.toThrow(/lost the committed response/);
|
|
} finally { lostResponse.mockRestore(); }
|
|
const committed = await f.snapshot();
|
|
const created = committed.projects.find(project => project.name === request.arguments.name)!;
|
|
expect(created).toBeDefined();
|
|
const recovered = await Promise.all(Array.from({ length: 4 }, () => new PaperclipRunnerToolAuthority(server.db, f).recover(request)));
|
|
expect(recovered).toEqual(Array.from({ length: 4 }, () => ({ result: expect.objectContaining({ id: created.id }) })));
|
|
await expect(new PaperclipRunnerToolAuthority(server.db, f).recover({ ...request, arguments: { ...request.arguments, name: "Changed" } })).rejects.toThrow(/different inputs/);
|
|
const after = await f.snapshot();
|
|
expect(after.projects.map(project => project.id).sort()).toEqual(committed.projects.map(project => project.id).sort());
|
|
expect(after.activity.filter(event => event.action === "project.created")).toHaveLength(1);
|
|
const foreign = await server.fixture({ disableWakeOnDemand: true });
|
|
expect(await foreign.authority.recover(request)).toBeNull();
|
|
await server.db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
|
|
await expect(new PaperclipRunnerToolAuthority(server.db, f).recover(request)).rejects.toThrow();
|
|
});
|
|
|
|
it("holds an unknown project mutation without creating a project or audit event", async () => {
|
|
const f = await server.fixture({ disableWakeOnDemand: true });
|
|
const before = await f.snapshot();
|
|
const request = { tool: "create_project", arguments: { name: "Uncertain project", idempotencyKey: "unknown" }, callId: randomUUID() };
|
|
expect(await f.authority.recover(request)).toBeNull();
|
|
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
|
|
const response = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/projects`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-Paperclip-Receipt-Only": "true" },
|
|
body: JSON.stringify(request.arguments),
|
|
});
|
|
expect(response.status).toBe(204);
|
|
const missingKey = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/projects`, {
|
|
method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "X-Paperclip-Receipt-Only": "true" },
|
|
body: JSON.stringify({ name: "Missing receipt key" }),
|
|
});
|
|
expect(missingKey.status).toBe(422);
|
|
const after = await f.snapshot();
|
|
expect(after.projects.map(project => project.id).sort()).toEqual(before.projects.map(project => project.id).sort());
|
|
expect(after.activity.filter(event => event.action === "project.created")).toEqual([]);
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|