mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Low-trust agents must work within their assigned scope. > - Task creation currently rejects these agents before checking assignment permission or scope. > - Persistent instruction saves also reject direct requests from authorized chat owners. > - This PR checks the requested action and its recorded authority instead of denying all such work. > - Agents can organize permitted work and follow their owner's instruction-edit requests while outside work stays restricted. ## Linked Issues or Issue Description **What happened?** Low-trust agents cannot create self-assigned tasks or subtasks, even within their allowed scope. An authorized user also cannot ask an agent in their own Agent Chat to update its managed `AGENTS.md`. Agent-folder collection can hide the permission rejection behind a generic save failure. **Expected behavior** Allow task creation when assignment permissions and project or root-task scope permit it. Allow instruction self-edits during authenticated owner-chat execution, subject to the user's current edit permission. Outside tasks, subtasks, connector messages, and peer agents do not inherit that instruction authority. Explain the actual denial when a save fails. **Steps to reproduce** 1. Configure an active agent with `low_trust_review` and a project or root-task boundary. 2. Ask it to create an in-scope task assigned to itself, or a subtask of its own task. 3. As a user with permission to configure that agent, ask it in your Agent Chat to update its managed `AGENTS.md`. 4. Observe blanket permission denials rather than action-specific checks. **Paperclip version or commit** Rebased onto master at `8ec4b84e1`. This is a core authorization change, independent of adapter choice. **Deployment mode** Authenticated server. Regression coverage uses the server services, HTTP routes, native tool authority, and embedded PostgreSQL. Related work: #14775 adds human-directed task execution. #13599 concerns instruction-path configuration; this PR leaves that configuration restricted. #11988 proposes separate active-review instruction protection. #10693 reports unclear authorization denials on a different API surface. ## What Changed - Apply task-assignment checks to both HTTP creation routes and native task creation, including unassigned work. Preserve low-trust policy and source attribution on the created task and its initial plan. - Allow self-assigned decomposition within the permitted project or root-task tree. Resolve workspace-derived project scope before authorization, and reauthorize existing tasks before duplicate detection returns them. Keep cross-project and peer-assignment checks. - Derive instruction self-edit authority from the accepted run identity and authenticated owner-message wake. Recheck current permissions at save time. Bind retries to the same request and chat session. - Reject inherited instruction authority from outside tasks, subtasks, plugins, connectors, stale sessions, cancelled runs, and peer edits. - Surface permission errors in instruction and agent-folder save receipts. Tell chat agents to explain the rejected action and the specific restriction. - Update the low-trust policy and implementation documentation. ## Verification - All 297 tests in 11 focused server suites pass after the rebase. These cover owner-chat saves, private copies, warm agent directories, reset and retry boundaries, permission revocation, task creation routes, and native tool authority. - After review fixes, all 126 tests in the four affected authorization/chat suites pass. Workspace scope regressions and 146 existing creation/ownership/workspace-route tests also pass. - The final duplicate-task and CI fixes pass all 39 tests across chat-project tools, duplicate creation, environment-selection guards, and assignee-invokability routes. The duplicate-task test reproduced an unauthorized response before the fix and verifies denial plus permitted reuse afterward. - `pnpm --filter @paperclipai/server typecheck` passes after rebasing; `pnpm --filter @paperclipai/server exec tsc --noEmit` also passes after the review fixes. - `git diff --check origin/master...HEAD` passes. - Final head `7e73270b86748792649e4ae6fbc6879f73b42b73`: all 54 checks passed, with two expected skips and no pending or failed checks. This includes builds, typechecking, the full test matrix, end-to-end tests, runner verification, the canary dry run, and security scans. - Greptile is 5/5 on that exact head, with no unresolved review threads. This change has not been deployed to staging. ## Risks This changes authorization behavior. The instruction exception must not become an inherited task permission. The check uses server-owned execution records, requires the agent's own chat and instructions, and keeps normal protected-change and responsible-user checks. Saves fail closed when current provenance or permission is missing. Owner chat grants a turn-scoped capability; the server does not classify the message intent or require approval of the exact new file bytes. Prompt injection within an authorized owner-chat turn remains a model-level risk. This is the requested owner-chat trust boundary, without a new per-edit confirmation flow. No database migration or broad trust-preset change is required. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code editing, shell tools, and test execution. The exact runtime model ID and context-window size 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>
336 lines
26 KiB
TypeScript
336 lines
26 KiB
TypeScript
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("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();
|
|
});
|
|
});
|