mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents search tasks to find work that already exists. > - The search schema permits 200 results, but the server returns at most 100. > - The response does not tell the agent that more results exist. > - This pull request adds pages and reports the page size and continuation cursor. > - Agents can read the full accessible inventory before they decide that a task does not exist. ## Linked Issues or Issue Description **What happened?** A native `search_tasks` request with `limit: 200` returns only 100 tasks. Historical scans recovered 167 and 173 tasks with status partitions. This proves missing inventory and extra work. It does not prove duplicate task creation. **Expected behavior** Honor the declared limit. Report more results and give the caller a continuation cursor. **Steps to reproduce** Create 205 matching tasks in a test company. Call `search_tasks` with `limit: 200`. The baseline returns 100. The fixed path returns 200, then five when the caller uses `nextCursor`. **Paperclip version or commit** Baseline: `81ebe479e8db8a3d59c86cdfb894bd917734492f`. Related changes: #16979 changes UI status filters before pagination. #13699 adds public task URLs. Neither repairs this native limit. An exact `search_tasks` plus pagination PR search returned no result. Pattern: [Task searches omit cases beyond the first hundred](https://www.braintrust.dev/app/Paperclip/p/Paperclip%20Staging/patterns/c20462d6-05fd-4862-bfbd-7bec1f2bc159). ### Hypothesis and thesis for a fix The native authority uses `boundedLimit` with its default maximum of 100. The tool schema permits 200. The response contains only `tasks`. The raw request and result counts agree with this cause. Fix the native authority and its canonical tool contract. Keep the existing `tasks` field. Add `limit`, `returnedCount`, `hasMore` and `nextCursor`. Use ascending task IDs so task activity does not move results between pages. Apply current company, privacy and Muse read checks before each page. A cursor grants no access. Callers must keep the same filters and read every page. Concurrent task changes require a fresh scan. ## What Changed - Honor the native search limit of 1 to 200, with a default of 50. - Add ID cursor pagination and page metadata. - Describe complete inventory reads in the tool contract and access map. - Refresh the generated semantic contracts and seeded catalog identity. - Add regression coverage for complete inventories and access-filtered pages. ## Verification - Red: the database integration test requested 200 of 205 persisted tasks. Baseline returned 100. - Green: the same regression returns pages of 200 and five without duplicate IDs. - The native authority and Muse suites cover company exclusion, status filters, empty pages, invalid cursors and readable-only metadata. - Command: `pnpm --filter @paperclipai/server exec vitest run src/services/native-runtime/paperclip-runner-tool-authority.test.ts src/services/native-runtime/muse-native-visibility.test.ts`. - The test uses the real run-bound native authority and disposable PostgreSQL. It does not use an external model or change a deployed scanner. - Both native authority and Muse suites pass: 71 tests. Server direct `tsc --noEmit`, Runner `typecheck:typescript`, `node scripts/check-maps.mjs` and generated semantic contract checks pass. - Full typecheck and build cannot finish here because `cargo` is absent. The full local test command was stopped after about five minutes when the complete CI matrix was running. It has no complete verdict. Current-head CI passed, including typecheck, build, all general/serialized/Runner test suites and E2E. Greptile is 5/5 with no actionable findings at `392cb7424a1fa0ec73401d5e8a10a22b504fde32`. Optional Storybook and manual Docker workflows were skipped and are not executed coverage. The success-example correction also passed all 82 contract tests locally. ## Risks Task order changes to ascending ID. Pagination is not a snapshot. New or changed tasks can require a fresh scan. Each page retains the existing full company-list read and Muse authorization cost. This change adds no database query loop or new background work. Existing callers can still read `tasks`, but must follow `nextCursor` for a complete inventory. No scanner implementation was supplied. No migration or deployment is included. ## Model Used OpenAI GPT-6 through Codex, with tool use and code execution. The exact provider deployment ID and context limit 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) - [ ] 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 The task requires the supplied branch to stay unchanged. All applicable CI and review checks pass at the current head. Unmerged and not deployed. --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
467 lines
44 KiB
TypeScript
467 lines
44 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { LOW_TRUST_REVIEW_PRESET, DEFAULT_TRUST_PRESET } from "@paperclipai/shared";
|
|
import { and, desc, eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, approvals, authUsers, companies, companyMemberships, createDb, completionContracts, nativeRunResults, workAssessments, statusDecisions, issueApprovals, documents, documentRevisions, externalAgentHolds, heartbeatRuns, instanceUserRoles, issueAccessGrants, issues, issueDocuments, issueThreadInteractions, principalPermissionGrants, projectAccessMembers, projects } from "@paperclipai/db";
|
|
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
|
import { approvalReadSqlCondition, authorizationService, canActorReadIssuePrivacy, issueReadSqlCondition, projectReadSqlCondition, type AuthorizationActor } from "../authorization.js";
|
|
import { claimQueuedNativeReviewRun } from "./native-review-dispatch.js";
|
|
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
|
import { documentService } from "../documents.js";
|
|
import { buildLowTrustSourceTrust, buildPromotedSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../source-trust.js";
|
|
|
|
describe("Muse native visibility intersection", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
beforeAll(async () => { temporary = await startEmbeddedPostgresTestDatabase("muse-native-visibility-"); db = createDb(temporary.connectionString); }, 30000);
|
|
afterAll(async () => { await temporary?.cleanup(); });
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
async function fixture() {
|
|
const companyId = randomUUID(), agentId = randomUUID(), responsible = randomUUID(), authorizer = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Muse visibility", issuePrefix: `V${companyId.slice(0, 6)}` });
|
|
await db.insert(authUsers).values([responsible, authorizer].map(id => ({ id, name: id, email: `${id}@example.test`, createdAt: new Date(), updatedAt: new Date() })));
|
|
await db.insert(companyMemberships).values([responsible, authorizer].map(principalId => ({ companyId, principalType: "user", principalId, status: "active", membershipRole: "operator" })));
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Muse", role: "engineer", status: "idle", adapterType: "paperclip_runner", adapterConfig: { provider: "muse" }, runtimeConfig: {}, permissions: {} });
|
|
const [issue] = await db.insert(issues).values({ companyId, title: "Private task", visibility: "private", assigneeAgentId: agentId, responsibleUserId: responsible }).returning();
|
|
const actor: AuthorizationActor = { type: "agent", agentId, companyId, source: "agent_jwt", onBehalfOfUserId: responsible, authorizingUserId: authorizer };
|
|
return { companyId, agentId, responsible, authorizer, issue, actor };
|
|
}
|
|
async function documentFixture(lowTrustReader = false) {
|
|
const f = await fixture(), runId = randomUUID();
|
|
const executionPolicy = lowTrustReader ? { authorizationPolicy: { trustBoundary: {
|
|
mode: LOW_TRUST_REVIEW_PRESET, companyId: f.companyId, rootIssueId: f.issue.id,
|
|
} } } : null;
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId,
|
|
nativeIssueId: f.issue.id, responsibleUserId: f.responsible, status: "running", runtimeMode: "native",
|
|
invocationSource: "assignment", contextSnapshot: { issueId: f.issue.id, executionPolicy } });
|
|
await db.update(issues).set({ status: "in_progress", executionRunId: runId, executionPolicy: null }).where(eq(issues.id, f.issue.id));
|
|
await db.insert(issueAccessGrants).values({ issueId: f.issue.id, subjectType: "user", subjectId: f.authorizer, source: "explicit" });
|
|
const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.agentId,
|
|
issueId: f.issue.id, runId, museRuntime: true, externalAuthorizingUserId: f.authorizer,
|
|
assertBridgeAuthority: async () => {} });
|
|
return { ...f, runId, authority };
|
|
}
|
|
|
|
async function taskReadBoundaryFixture(restricted:boolean) {
|
|
const f=await documentFixture();
|
|
const [allowedProject,outsideProject]=await db.insert(projects).values([
|
|
{companyId:f.companyId,name:"Native allowed project"},{companyId:f.companyId,name:"Native outside project"},
|
|
]).returning();
|
|
await db.update(issues).set({projectId:allowedProject.id}).where(eq(issues.id,f.issue.id));
|
|
if(restricted)await db.update(heartbeatRuns).set({contextSnapshot:{issueId:f.issue.id,executionPolicy:{authorizationPolicy:{trustPreset:LOW_TRUST_REVIEW_PRESET,trustBoundary:{mode:LOW_TRUST_REVIEW_PRESET,companyId:f.companyId,projectIds:[allowedProject.id]}}}}}).where(eq(heartbeatRuns.id,f.runId));
|
|
const [allowed,outside]=await db.insert(issues).values([
|
|
{companyId:f.companyId,parentId:f.issue.id,projectId:allowedProject.id,title:"MUSE_READ_SCOPE_ALLOWED",description:"MUSE_READ_ALLOWED_BODY",visibility:"open",status:"done"},
|
|
{companyId:f.companyId,parentId:f.issue.id,projectId:outsideProject.id,title:"MUSE_READ_SCOPE_OUTSIDE",description:"MUSE_READ_OUTSIDE_BODY",visibility:"open",status:"done"},
|
|
]).returning();
|
|
const actor={...f.actor,runId:f.runId};
|
|
expect(await canActorReadIssuePrivacy(db,actor,outside)).toBe(true);
|
|
const decisions=await authorizationService(db).decideReads(actor,[allowed,outside].map(issue=>({type:"issue" as const,companyId:f.companyId,issueId:issue.id})));
|
|
expect(decisions.map(decision=>decision.allowed)).toEqual([true,!restricted]);
|
|
return {...f,allowed,outside};
|
|
}
|
|
async function recordedChildReview(f:Awaited<ReturnType<typeof taskReadBoundaryFixture>>,issueId:string) {
|
|
const contractId=randomUUID(),sourceRunId=randomUUID(),resultId=randomUUID(),assessmentId=randomUUID(),decisionId=randomUUID(),interactionId=randomUUID();
|
|
await db.insert(completionContracts).values({id:contractId,companyId:f.companyId,issueId,revision:1,schemaVersion:"paperclip.completion-contract.v1",policyVersion:"native-read-fixture",risk:"standard",completionAuthority:"server_arbiter",incompleteCriteriaPolicy:"preserve_non_terminal",contractJson:{objective:"Review child"},canonicalSha256:contractId,createdByActorType:"system",createdByActorId:"test"});
|
|
await db.insert(heartbeatRuns).values({id:sourceRunId,companyId:f.companyId,agentId:f.agentId,nativeIssueId:issueId,status:"succeeded",runtimeMode:"native",completionContractId:contractId});
|
|
await db.insert(nativeRunResults).values({id:resultId,companyId:f.companyId,issueId,runId:sourceRunId,completionContractId:contractId,serverFingerprint:resultId,schemaStatus:"accepted",resultJson:{},canonicalSha256:resultId});
|
|
await db.insert(workAssessments).values({id:assessmentId,companyId:f.companyId,issueId,runId:sourceRunId,contractId,resultId,triggerKind:"native_result",triggerActorCompanyId:f.companyId,priorIssueStatus:"in_progress",priorStatusVersion:0,policyVersion:"native-read-fixture",assessmentJson:{},inputDigest:assessmentId});
|
|
await db.insert(statusDecisions).values({id:decisionId,companyId:f.companyId,issueId,runId:sourceRunId,assessmentId,decisionVersion:1,policyVersion:"native-read-fixture",fromStatus:"in_progress",toStatus:"done",reasonCode:"explicit_review",decisionJson:{},decisionDigest:decisionId,applicationState:"applied"});
|
|
await db.insert(issueThreadInteractions).values({id:interactionId,companyId:f.companyId,issueId,kind:"request_confirmation",status:"accepted",sourceRunId,createdByAgentId:f.agentId,resolvedByUserId:f.authorizer,resolvedAt:new Date(),payload:{version:1,prompt:"Review child",target:{type:"custom",key:"native_completion_review",revisionId:decisionId}}});
|
|
return interactionId;
|
|
}
|
|
it.each([true,false].flatMap(restricted=>["search_tasks","child_tasks","child_review_outcomes","approval_tasks"].map(shape=>({restricted,shape}))))("applies ordinary per-task decisions to native Muse result collections ($shape; restricted: $restricted)",async({restricted,shape})=>{
|
|
const f=await taskReadBoundaryFixture(restricted);
|
|
let approvalId:string|undefined;
|
|
if(shape==="child_review_outcomes"){
|
|
await recordedChildReview(f,f.allowed.id);await recordedChildReview(f,f.outside.id);
|
|
}
|
|
if(shape==="approval_tasks"){
|
|
const [approval]=await db.insert(approvals).values({companyId:f.companyId,type:"hire_agent",status:"pending",payload:{issueId:f.issue.id}}).returning();approvalId=approval.id;
|
|
await db.insert(issueApprovals).values([f.allowed,f.outside].map(issue=>({companyId:f.companyId,issueId:issue.id,approvalId:approval.id,linkedByAgentId:f.agentId})));
|
|
}
|
|
const result=await f.authority.execute({tool:shape==="search_tasks"?"search_tasks":shape==="approval_tasks"?"get_approval_context":"get_task_context",callId:"per-task-"+shape,arguments:shape==="search_tasks"?{query:"MUSE_READ_SCOPE",limit:10}:shape==="approval_tasks"?{approvalId}:{}});
|
|
const ids=shape==="child_review_outcomes"
|
|
?(result as {childReviewOutcomes:Array<{result:{childTaskId:string}}>}).childReviewOutcomes.map(row=>row.result.childTaskId)
|
|
:shape==="child_tasks"?(result as {childTasks:Array<{id:string}>}).childTasks.map(row=>row.id)
|
|
:(result as {tasks:Array<{id:string}>}).tasks.map(row=>row.id);
|
|
expect(ids).toContain(f.allowed.id);
|
|
if(restricted){expect(ids).not.toContain(f.outside.id);for(const value of [f.outside.id,f.outside.title,f.outside.description])expect(JSON.stringify(result)).not.toContain(value);}
|
|
else expect(ids).toContain(f.outside.id);
|
|
if(shape==="search_tasks"){
|
|
const outsideOnly=await f.authority.execute({tool:"search_tasks",callId:"outside-only",arguments:{query:"MUSE_READ_SCOPE_OUTSIDE",limit:1}});
|
|
expect(outsideOnly).toMatchObject({tasks:restricted?[]:[{id:f.outside.id}],returnedCount:restricted?0:1,hasMore:false,nextCursor:null});
|
|
const page=await f.authority.execute({tool:"search_tasks",callId:"authorized-page",arguments:{query:"MUSE_READ_SCOPE",limit:1}}) as {tasks:Array<{id:string}>;hasMore:boolean;nextCursor:string|null};
|
|
expect(page.tasks).toHaveLength(1);
|
|
expect(page.hasMore).toBe(!restricted);
|
|
if(restricted)expect(page.nextCursor).toBeNull();
|
|
else {
|
|
const next=await f.authority.execute({tool:"search_tasks",callId:"authorized-next-page",arguments:{query:"MUSE_READ_SCOPE",limit:1,cursor:page.nextCursor}}) as {tasks:Array<{id:string}>;hasMore:boolean;nextCursor:string|null};
|
|
expect([...page.tasks,...next.tasks].map(task=>task.id).sort()).toEqual([f.allowed.id,f.outside.id].sort());
|
|
expect(next).toMatchObject({hasMore:false,nextCursor:null});
|
|
}
|
|
}
|
|
});
|
|
it("retains the child search fallback when the authorized child follows a capped denied prefix",async()=>{
|
|
const f=await taskReadBoundaryFixture(true),older=new Date("2026-01-01T00:00:00.000Z"),newer=new Date("2026-01-01T01:00:00.000Z");
|
|
await db.update(issues).set({createdAt:older}).where(eq(issues.id,f.allowed.id));
|
|
await db.update(issues).set({createdAt:newer}).where(eq(issues.id,f.outside.id));
|
|
const denied=await db.insert(issues).values(Array.from({length:100},(_,index)=>({
|
|
id:f.companyId.slice(0,24)+index.toString(16).padStart(12,"0"),companyId:f.companyId,parentId:f.issue.id,
|
|
projectId:f.outside.projectId,title:"MUSE_DENIED_PREFIX_"+index,description:"MUSE_DENIED_PREFIX_BODY_"+index,
|
|
visibility:"open",status:"done",createdAt:newer,
|
|
}))).returning();
|
|
const actor={...f.actor,runId:f.runId};
|
|
const candidates=await db.select({id:issues.id}).from(issues).where(and(eq(issues.companyId,f.companyId),eq(issues.parentId,f.issue.id),await issueReadSqlCondition(db,actor)))
|
|
.orderBy(desc(issues.createdAt),desc(issues.id)).limit(101);
|
|
expect(candidates.map(row=>row.id)).toEqual([f.outside.id,...denied.map(row=>row.id)].sort().reverse());
|
|
expect(candidates.map(row=>row.id)).not.toContain(f.allowed.id);
|
|
const result=await f.authority.execute({tool:"get_task_context",callId:"capped-denied-prefix",arguments:{}}) as {childTasksTruncated:boolean;childTasks:Array<{id:string}>};
|
|
expect(result.childTasksTruncated).toBe(true);
|
|
expect(result.childTasks).toEqual([]);
|
|
for(const row of [f.outside,...denied])for(const value of [row.id,row.title,row.description])expect(JSON.stringify(result)).not.toContain(value);
|
|
expect(await f.authority.execute({tool:"search_tasks",callId:"older-authorized-search",arguments:{query:"MUSE_READ_SCOPE_ALLOWED",limit:1}}))
|
|
.toMatchObject({tasks:[{id:f.allowed.id,title:f.allowed.title,description:f.allowed.description}]});
|
|
});
|
|
it("preserves ordinary unrestricted native task search results",async()=>{
|
|
const f=await taskReadBoundaryFixture(false);
|
|
await db.update(agents).set({adapterConfig:{provider:"codex"}}).where(eq(agents.id,f.agentId));
|
|
const authority=new PaperclipRunnerToolAuthority(db,{companyId:f.companyId,agentId:f.agentId,issueId:f.issue.id,runId:f.runId});
|
|
expect(await authority.execute({tool:"search_tasks",callId:"ordinary-search",arguments:{query:"MUSE_READ_SCOPE",limit:10}})).toMatchObject({tasks:expect.arrayContaining([{...f.allowed},{...f.outside}].map(task=>expect.objectContaining({id:task.id,title:task.title,description:task.description})))});
|
|
});
|
|
|
|
it("tags actual Muse native document writes with the retained low-trust run before storing or replaying them", async () => {
|
|
const f = await documentFixture(true);
|
|
const sourceTrust = buildLowTrustSourceTrust({ issueId: f.issue.id, runId: f.runId, agentId: f.agentId });
|
|
const call = { tool: "write_document", callId: "low-trust-write", arguments: {
|
|
idempotencyKey: "low-trust-write", key: "report", title: "Report", body: "LOW_TRUST_NATIVE_WRITE_CANARY", baseRevisionId: null,
|
|
} };
|
|
const written = await f.authority.execute(call);
|
|
expect(written).toMatchObject({ disposition: "applied", document: { body: "LOW_TRUST_NATIVE_WRITE_CANARY", sourceTrust } });
|
|
expect(await documentService(db).getIssueDocumentByKey(f.issue.id, "report"))
|
|
.toMatchObject({ body: "LOW_TRUST_NATIVE_WRITE_CANARY", sourceTrust });
|
|
expect(await f.authority.execute({ ...call, callId: "low-trust-write-replay" })).toEqual(written);
|
|
const current = await documentService(db).getIssueDocumentByKey(f.issue.id, "report");
|
|
expect(await f.authority.execute({ ...call, callId: "low-trust-update", arguments: { ...call.arguments,
|
|
idempotencyKey: "low-trust-update", body: "LOW_TRUST_NATIVE_UPDATE_CANARY", baseRevisionId: current!.latestRevisionId,
|
|
} })).toMatchObject({ disposition: "applied", document: { body: "LOW_TRUST_NATIVE_UPDATE_CANARY", sourceTrust } });
|
|
expect(await documentService(db).getIssueDocumentByKey(f.issue.id, "report"))
|
|
.toMatchObject({ body: "LOW_TRUST_NATIVE_UPDATE_CANARY", sourceTrust });
|
|
expect(await documentService(db).listIssueDocumentRevisions(f.issue.id, "report")).toHaveLength(2);
|
|
});
|
|
|
|
it.each([false, true])("projects Muse current documents, old revisions and exact accepted plans for the named reader (low trust: %s)", async lowTrustReader => {
|
|
const f = await documentFixture(lowTrustReader), writerRunId = randomUUID();
|
|
const lowPolicy = { authorizationPolicy: { trustBoundary: {
|
|
mode: LOW_TRUST_REVIEW_PRESET, companyId: f.companyId, rootIssueId: f.issue.id,
|
|
} } };
|
|
await db.insert(heartbeatRuns).values({ id: writerRunId, companyId: f.companyId, agentId: f.agentId,
|
|
nativeIssueId: f.issue.id, status: "succeeded", contextSnapshot: { issueId: f.issue.id, executionPolicy: lowPolicy } });
|
|
const quarantine = buildLowTrustSourceTrust({ issueId: f.issue.id, runId: writerRunId, agentId: f.agentId });
|
|
const service = documentService(db);
|
|
const first = await service.upsertIssueDocument({ issueId: f.issue.id, key: "plan", title: "Plan", format: "markdown",
|
|
body: "LOW_TRUST_REVISION_CANARY", changeSummary: "LOW_TRUST_SUMMARY_CANARY", createdByAgentId: f.agentId,
|
|
createdByRunId: writerRunId, sourceTrust: quarantine });
|
|
const currentBody = lowTrustReader ? "LOW_TRUST_REVISION_CANARY" : LOW_TRUST_QUARANTINED_BODY;
|
|
expect(await f.authority.execute({ tool: "read_document", callId: "read-quarantined", arguments: { key: "plan" } }))
|
|
.toMatchObject({ document: { id: first.document.id, body: currentBody, sourceTrust: quarantine } });
|
|
expect(await f.authority.execute({ tool: "list_documents", callId: "list-quarantined", arguments: {} }))
|
|
.toMatchObject({ documents: [{ id: first.document.id, body: currentBody, sourceTrust: quarantine }] });
|
|
const promotedFirst = buildPromotedSourceTrust({ sourceIssueId: f.issue.id, sourceArtifactKind: "document",
|
|
sourceArtifactId: first.document.id, promotedByActorType: "user", promotedByActorId: f.authorizer });
|
|
await db.update(documents).set({ sourceTrust: promotedFirst }).where(eq(documents.id, first.document.id));
|
|
expect(await f.authority.execute({ tool: "read_document", callId: "read-explicitly-promoted", arguments: { key: "plan" } }))
|
|
.toMatchObject({ document: { body: "LOW_TRUST_REVISION_CANARY", sourceTrust: promotedFirst } });
|
|
const second = await service.upsertIssueDocument({ issueId: f.issue.id, key: "plan", title: "Plan", format: "markdown",
|
|
body: "Trusted Board replacement", baseRevisionId: first.document.latestRevisionId, createdByUserId: f.authorizer });
|
|
const promotion = buildPromotedSourceTrust({ sourceIssueId: f.issue.id, sourceArtifactKind: "document",
|
|
sourceArtifactId: second.document.id, promotedByActorType: "user", promotedByActorId: f.authorizer });
|
|
await db.update(documents).set({ sourceTrust: promotion }).where(eq(documents.id, first.document.id));
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: {
|
|
issueId: f.issue.id, ...(lowTrustReader ? { executionPolicy: lowPolicy } : {}),
|
|
planReviewInteraction: { acceptedTargetRevision: { issueId: f.issue.id, documentId: first.document.id,
|
|
key: "plan", revisionId: first.document.latestRevisionId, revisionNumber: first.document.latestRevisionNumber } },
|
|
} }).where(eq(heartbeatRuns.id, f.runId));
|
|
const storedBefore = await db.select().from(documentRevisions).where(eq(documentRevisions.documentId, first.document.id));
|
|
const history = await f.authority.execute({ tool: "list_document_revisions", callId: "history-after-rewrite", arguments: { key: "plan" } });
|
|
expect(history).toMatchObject({ revisions: [
|
|
{ id: second.document.latestRevisionId, body: "Trusted Board replacement" },
|
|
{ id: first.document.latestRevisionId, body: currentBody,
|
|
changeSummary: lowTrustReader ? "LOW_TRUST_SUMMARY_CANARY" : null },
|
|
] });
|
|
const context = await f.authority.execute({ tool: "get_task_context", callId: "accepted-old-revision", arguments: {} });
|
|
expect(context).toMatchObject({ acceptedPlan: { documentId: first.document.id, revisionId: first.document.latestRevisionId,
|
|
revisionNumber: first.document.latestRevisionNumber, markdown: currentBody } });
|
|
expect(await f.authority.execute({ tool: "read_document", callId: "read-promoted-current", arguments: { key: "plan" } }))
|
|
.toMatchObject({ document: { id: first.document.id, body: "Trusted Board replacement", sourceTrust: promotion } });
|
|
if (!lowTrustReader) {
|
|
expect(JSON.stringify(history)).not.toContain("LOW_TRUST_REVISION_CANARY");
|
|
expect(JSON.stringify(history)).not.toContain("LOW_TRUST_SUMMARY_CANARY");
|
|
expect(JSON.stringify(context)).not.toContain("LOW_TRUST_REVISION_CANARY");
|
|
}
|
|
expect(await db.select().from(documentRevisions).where(eq(documentRevisions.documentId, first.document.id))).toEqual(storedBefore);
|
|
expect(await service.getIssueDocumentByKey(f.issue.id, "plan"))
|
|
.toMatchObject({ body: "Trusted Board replacement", sourceTrust: promotion });
|
|
});
|
|
|
|
it("quarantines legacy untagged current Muse output using its original retained run without mutable writer permissions", async () => {
|
|
const f = await documentFixture(), writerRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: writerRunId, companyId: f.companyId, agentId: f.agentId,
|
|
nativeIssueId: f.issue.id, status: "succeeded", contextSnapshot: { issueId: f.issue.id, executionPolicy: {
|
|
trustPreset: LOW_TRUST_REVIEW_PRESET, authorizationPolicy: { trustBoundary: {
|
|
mode: LOW_TRUST_REVIEW_PRESET, companyId: f.companyId, rootIssueId: f.issue.id,
|
|
} },
|
|
} } });
|
|
const written = await documentService(db).upsertIssueDocument({ issueId: f.issue.id, key: "legacy", title: "Legacy",
|
|
format: "markdown", body: "UNTAGGED_LOW_TRUST_CANARY", createdByAgentId: f.agentId, createdByRunId: writerRunId });
|
|
expect(written.document.sourceTrust).toBeNull();
|
|
expect(await f.authority.execute({ tool: "read_document", callId: "legacy-current", arguments: { key: "legacy" } }))
|
|
.toMatchObject({ document: { body: LOW_TRUST_QUARANTINED_BODY, sourceTrust: null } });
|
|
expect(await f.authority.execute({ tool: "list_documents", callId: "legacy-list", arguments: {} }))
|
|
.toMatchObject({ documents: [{ body: LOW_TRUST_QUARANTINED_BODY }] });
|
|
expect(await documentService(db).getIssueDocumentByKey(f.issue.id, "legacy"))
|
|
.toMatchObject({ body: "UNTAGGED_LOW_TRUST_CANARY", sourceTrust: null });
|
|
});
|
|
|
|
it.each(["missing_run", "foreign_company", "wrong_writer", "wrong_task", "missing_policy", "missing_user"])(
|
|
"withholds unknown Muse revision provenance without claiming quarantine (%s)", async kind => {
|
|
const f = await documentFixture(), writerRunId = randomUUID();
|
|
let writerCompanyId = f.companyId, writerAgentId = f.agentId;
|
|
if (kind === "foreign_company") {
|
|
writerCompanyId = randomUUID();
|
|
await db.insert(companies).values({ id: writerCompanyId, name: "Other writer", issuePrefix: `F${writerCompanyId.slice(0, 6)}` });
|
|
}
|
|
if (kind === "wrong_writer" || kind === "foreign_company") {
|
|
writerAgentId = randomUUID();
|
|
await db.insert(agents).values({ id: writerAgentId, companyId: writerCompanyId, name: "Other writer", adapterType: "paperclip_runner" });
|
|
}
|
|
if (kind !== "missing_run" && kind !== "missing_user") await db.insert(heartbeatRuns).values({ id: writerRunId,
|
|
companyId: writerCompanyId, agentId: writerAgentId, nativeIssueId: kind === "wrong_task" ? null : f.issue.id,
|
|
status: "succeeded", contextSnapshot: { issueId: kind === "wrong_task" ? randomUUID() : f.issue.id,
|
|
...(kind === "missing_policy" ? {} : { executionPolicy: { trustPreset: DEFAULT_TRUST_PRESET } }) } });
|
|
const service = documentService(db);
|
|
const first = await service.upsertIssueDocument({ issueId: f.issue.id, key: "plan", title: "Plan", format: "markdown",
|
|
body: "UNKNOWN_REVISION_CANARY", changeSummary: "UNKNOWN_SUMMARY_CANARY",
|
|
...(kind === "missing_user" ? { createdByUserId: "not-an-authenticated-user" }
|
|
: { createdByAgentId: f.agentId, createdByRunId: kind === "missing_run" ? null : writerRunId }) });
|
|
const second = await service.upsertIssueDocument({ issueId: f.issue.id, key: "plan", title: "Plan", format: "markdown",
|
|
body: "Trusted current Board text", baseRevisionId: first.document.latestRevisionId, createdByUserId: f.authorizer });
|
|
const storedBefore = await db.select().from(documentRevisions).where(eq(documentRevisions.documentId, first.document.id));
|
|
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issue.id, planReviewInteraction: {
|
|
acceptedTargetRevision: { key: "plan", revisionId: first.document.latestRevisionId },
|
|
} } }).where(eq(heartbeatRuns.id, f.runId));
|
|
const unknownBody = "[Document revision content omitted: historical source trust could not be established.]";
|
|
const history = await f.authority.execute({ tool: "list_document_revisions", callId: "unknown-history", arguments: { key: "plan" } });
|
|
expect(history).toMatchObject({ revisions: [
|
|
{ id: second.document.latestRevisionId, body: "Trusted current Board text" },
|
|
{ id: first.document.latestRevisionId, body: unknownBody, changeSummary: null },
|
|
] });
|
|
expect(JSON.stringify(history)).not.toContain("UNKNOWN_REVISION_CANARY");
|
|
expect(JSON.stringify(history)).not.toContain("UNKNOWN_SUMMARY_CANARY");
|
|
expect(JSON.stringify(history)).not.toContain(LOW_TRUST_QUARANTINED_BODY);
|
|
expect(await f.authority.execute({ tool: "get_task_context", callId: "unknown-accepted", arguments: {} }))
|
|
.toMatchObject({ acceptedPlan: { revisionId: first.document.latestRevisionId, markdown: unknownBody } });
|
|
expect(await f.authority.execute({ tool: "read_document", callId: "known-current", arguments: { key: "plan" } }))
|
|
.toMatchObject({ document: { body: "Trusted current Board text" } });
|
|
expect(await db.select().from(documentRevisions).where(eq(documentRevisions.documentId, first.document.id))).toEqual(storedBefore);
|
|
},
|
|
);
|
|
|
|
it("preserves verified standard-run and authenticated Board revision bodies for higher-trust Muse readers", async () => {
|
|
const f = await documentFixture(), writerRunId = randomUUID(), service = documentService(db);
|
|
await db.insert(heartbeatRuns).values({ id: writerRunId, companyId: f.companyId, agentId: f.agentId,
|
|
nativeIssueId: f.issue.id, status: "succeeded", contextSnapshot: { issueId: f.issue.id,
|
|
executionPolicy: { trustPreset: DEFAULT_TRUST_PRESET } } });
|
|
const first = await service.upsertIssueDocument({ issueId: f.issue.id, key: "report", title: "Report", format: "markdown",
|
|
body: "Trusted standard-run revision", createdByAgentId: f.agentId, createdByRunId: writerRunId });
|
|
const second = await service.upsertIssueDocument({ issueId: f.issue.id, key: "report", title: "Report", format: "markdown",
|
|
body: "Trusted Board revision", baseRevisionId: first.document.latestRevisionId, createdByUserId: f.authorizer });
|
|
await service.upsertIssueDocument({ issueId: f.issue.id, key: "report", title: "Report", format: "markdown",
|
|
body: "Trusted current native revision", baseRevisionId: second.document.latestRevisionId,
|
|
createdByAgentId: f.agentId, createdByRunId: f.runId });
|
|
expect(await f.authority.execute({ tool: "list_document_revisions", callId: "trusted-history", arguments: { key: "report" } }))
|
|
.toMatchObject({ revisions: [ { body: "Trusted current native revision" }, { body: "Trusted Board revision" },
|
|
{ body: "Trusted standard-run revision" } ] });
|
|
expect(await f.authority.execute({ tool: "read_document", callId: "trusted-current", arguments: { key: "report" } }))
|
|
.toMatchObject({ document: { body: "Trusted current native revision", sourceTrust: null } });
|
|
});
|
|
|
|
it("projects the exact accepted source-conversation plan before exposing it to a Muse child task", async () => {
|
|
const f = await documentFixture(), sourceRunId = randomUUID();
|
|
const [source] = await db.insert(issues).values({ companyId: f.companyId, title: "Source conversation", visibility: "open",
|
|
conversationAgentId: f.agentId, assigneeAgentId: f.agentId, conversationUserId: f.responsible, conversationState: "active" }).returning();
|
|
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId: f.companyId, agentId: f.agentId, nativeIssueId: source.id,
|
|
status: "succeeded", contextSnapshot: { issueId: source.id, executionPolicy: { authorizationPolicy: { trustBoundary: {
|
|
mode: LOW_TRUST_REVIEW_PRESET, companyId: f.companyId, rootIssueId: source.id,
|
|
} } } } });
|
|
const service = documentService(db);
|
|
const first = await service.upsertIssueDocument({ issueId: source.id, key: "plan", title: "Plan", format: "markdown",
|
|
body: "LOW_TRUST_SOURCE_PLAN_CANARY", createdByAgentId: f.agentId, createdByRunId: sourceRunId,
|
|
sourceTrust: buildLowTrustSourceTrust({ issueId: source.id, runId: sourceRunId, agentId: f.agentId }) });
|
|
await service.upsertIssueDocument({ issueId: source.id, key: "plan", title: "Plan", format: "markdown",
|
|
body: "Trusted newer source text", baseRevisionId: first.document.latestRevisionId, createdByUserId: f.authorizer });
|
|
const [accepted] = await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: source.id,
|
|
kind: "request_confirmation", status: "accepted", resolvedAt: new Date(f.issue.createdAt.getTime() - 1),
|
|
payload: { version: 1, prompt: "Approve plan", target: { type: "issue_document", issueId: source.id,
|
|
key: "plan", revisionId: first.document.latestRevisionId } } }).returning();
|
|
await db.update(issues).set({ originRunId: sourceRunId }).where(eq(issues.id, f.issue.id));
|
|
const context = await f.authority.execute({ tool: "get_task_context", callId: "source-plan-context", arguments: {} });
|
|
expect(context).toMatchObject({ sourcePlanApproval: { sourceIssueId: source.id, interactionId: accepted.id,
|
|
revisionId: first.document.latestRevisionId, markdown: LOW_TRUST_QUARANTINED_BODY } });
|
|
expect(JSON.stringify(context)).not.toContain("LOW_TRUST_SOURCE_PLAN_CANARY");
|
|
expect((await service.listIssueDocumentRevisions(source.id, "plan"))[1])
|
|
.toMatchObject({ id: first.document.latestRevisionId, body: "LOW_TRUST_SOURCE_PLAN_CANARY" });
|
|
});
|
|
|
|
it("rechecks current Muse authorizer and company authority before all native document projections", async () => {
|
|
const f = await documentFixture();
|
|
await documentService(db).upsertIssueDocument({ issueId: f.issue.id, key: "report", title: "Report", format: "markdown",
|
|
body: "Trusted scoped document", createdByUserId: f.authorizer });
|
|
await db.update(companyMemberships).set({ status: "inactive" })
|
|
.where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.authorizer)));
|
|
for (const tool of ["read_document", "list_documents", "list_document_revisions", "get_task_context"]) {
|
|
await expect(f.authority.execute({ tool, callId: tool, arguments: { key: "report" } })).rejects.toThrow("no longer available");
|
|
}
|
|
const foreign = new PaperclipRunnerToolAuthority(db, { companyId: randomUUID(), agentId: f.agentId,
|
|
issueId: f.issue.id, runId: f.runId, museRuntime: true, externalAuthorizingUserId: f.authorizer,
|
|
assertBridgeAuthority: async () => {} });
|
|
await expect(foreign.execute({ tool: "read_document", callId: "foreign", arguments: { key: "report" } }))
|
|
.rejects.toThrow("binding_not_authorized");
|
|
});
|
|
|
|
it.each(["enforce", "shadow", "off"])("filters direct and paginated reads before data leaves the DB in %s mode", async mode => {
|
|
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", mode);
|
|
const f = await fixture();
|
|
await db.insert(instanceUserRoles).values({ userId: f.authorizer, role: "instance_admin" });
|
|
expect(await canActorReadIssuePrivacy(db, f.actor, f.issue)).toBe(false);
|
|
expect(await db.select({ id: issues.id }).from(issues).where(and(eq(issues.companyId, f.companyId), await issueReadSqlCondition(db, f.actor))).limit(1)).toEqual([]);
|
|
expect((await authorizationService(db).decide({ actor: f.actor, action: "issue:read", resource: { type: "issue", companyId: f.companyId, issueId: f.issue.id } })).allowed).toBe(false);
|
|
const [grant] = await db.insert(issueAccessGrants).values({ issueId: f.issue.id, subjectType: "user", subjectId: f.authorizer, source: "explicit" }).returning();
|
|
expect(await canActorReadIssuePrivacy(db, f.actor, f.issue)).toBe(true);
|
|
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
|
|
expect(await canActorReadIssuePrivacy(db, f.actor, f.issue)).toBe(false);
|
|
});
|
|
it("intersects project and approval reads with current authorizer membership", async () => {
|
|
const f = await fixture();
|
|
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private project", visibility: "private" }).returning();
|
|
await db.insert(projectAccessMembers).values([{ subjectType: "agent", subjectId: f.agentId }, { subjectType: "user", subjectId: f.responsible }].map(subject => ({ ...subject, companyId: f.companyId, projectId: project.id })));
|
|
expect(await db.select({ id: projects.id }).from(projects).where(and(eq(projects.companyId, f.companyId), await projectReadSqlCondition(db, f.actor)))).toEqual([]);
|
|
await db.insert(projectAccessMembers).values({ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.authorizer });
|
|
expect(await db.select({ id: projects.id }).from(projects).where(and(eq(projects.id, project.id), await projectReadSqlCondition(db, f.actor)))).toHaveLength(1);
|
|
const [approval] = await db.insert(approvals).values({ companyId: f.companyId, type: "hire_agent", status: "pending", payload: { issueId: f.issue.id } }).returning();
|
|
expect(await db.select({ id: approvals.id }).from(approvals).where(and(eq(approvals.id, approval.id), await approvalReadSqlCondition(db, f.actor)))).toEqual([]);
|
|
await db.insert(issueAccessGrants).values({ issueId: f.issue.id, subjectType: "user", subjectId: f.authorizer, source: "explicit" });
|
|
expect(await db.select({ id: approvals.id }).from(approvals).where(and(eq(approvals.id, approval.id), await approvalReadSqlCondition(db, f.actor)))).toHaveLength(1);
|
|
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.authorizer)));
|
|
expect(await canActorReadIssuePrivacy(db, f.actor, f.issue)).toBe(false);
|
|
});
|
|
it("filters native task search with the same authorizer and responsible-user predicates", async () => {
|
|
const f = await fixture(), runId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.issue.id,
|
|
responsibleUserId: f.responsible, status: "running", runtimeMode: "native", invocationSource: "assignment" });
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issue.id));
|
|
await db.insert(issueAccessGrants).values({ issueId: f.issue.id, subjectType: "user", subjectId: f.authorizer, source: "explicit" });
|
|
const [hidden] = await db.insert(issues).values({ companyId: f.companyId, title: "Another private task", visibility: "private",
|
|
assigneeAgentId: f.agentId, responsibleUserId: f.responsible }).returning();
|
|
const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.agentId, issueId: f.issue.id, runId,
|
|
museRuntime: true, externalAuthorizingUserId: f.authorizer, assertBridgeAuthority: async () => {} });
|
|
const result = await authority.execute({ tool: "search_tasks", callId: "search", arguments: { limit: 10 } });
|
|
expect(result).toMatchObject({ tasks: [{ id: f.issue.id }] });
|
|
expect(JSON.stringify(result)).not.toContain(hidden.id);
|
|
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.authorizer)));
|
|
await expect(authority.execute({ tool: "search_tasks", callId: "search-again", arguments: {} })).rejects.toThrow("no longer available");
|
|
});
|
|
|
|
it.each(["create_task", "reassign_task"])("intersects native %s with current authorizer assignment scope before writes or wakes", async tool => {
|
|
vi.stubEnv("PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE", "shadow");
|
|
const f = await fixture(), runId = randomUUID();
|
|
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Assignment scope" }).returning();
|
|
const [protectedAgent] = await db.insert(agents).values({ companyId: f.companyId, name: "Protected target", status: "active", role: "engineer",
|
|
adapterType: "process", adapterConfig: {}, permissions: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
|
const [target] = await db.insert(issues).values({ companyId: f.companyId, projectId: project.id, title: "Reassignment target", status: "todo" }).returning();
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.issue.id,
|
|
responsibleUserId: f.responsible, status: "running", runtimeMode: "native", invocationSource: "assignment" });
|
|
await db.update(issues).set({ status: "in_progress", projectId: project.id, executionRunId: runId }).where(eq(issues.id, f.issue.id));
|
|
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId, status: "active", membershipRole: "member" });
|
|
await db.insert(principalPermissionGrants).values([
|
|
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign" },
|
|
{ companyId: f.companyId, principalType: "user", principalId: f.responsible, permissionKey: "tasks:assign" },
|
|
]);
|
|
await db.insert(issueAccessGrants).values({ issueId: f.issue.id, subjectType: "user", subjectId: f.authorizer, source: "explicit" });
|
|
// Instance-wide elevation must not become permission granted to Muse.
|
|
await db.insert(instanceUserRoles).values({ userId: f.authorizer, role: "instance_admin" });
|
|
const enqueueWakeup = vi.fn(async () => null);
|
|
const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.agentId, issueId: f.issue.id, runId,
|
|
museRuntime: true, externalAuthorizingUserId: f.authorizer, assertBridgeAuthority: async () => {}, enqueueWakeup });
|
|
const call = { tool, callId: "authorizer-scope", arguments: tool === "create_task"
|
|
? { idempotencyKey: "scope", title: "Protected child", assigneeActorId: protectedAgent.id }
|
|
: { idempotencyKey: "scope", taskId: target.id, assigneeActorId: protectedAgent.id,
|
|
expectedAssigneeActorId: null, expectedStatusVersion: target.statusVersion, reason: "Scoped reassignment" } };
|
|
const assertUnchanged = async () => {
|
|
expect(await db.select({ id: issues.id }).from(issues).where(eq(issues.parentId, f.issue.id))).toEqual([]);
|
|
expect(await db.select({ owner: issues.assigneeAgentId, version: issues.statusVersion }).from(issues).where(eq(issues.id, target.id)))
|
|
.toEqual([{ owner: null, version: target.statusVersion }]);
|
|
expect(enqueueWakeup).not.toHaveBeenCalled();
|
|
};
|
|
await expect(authority.execute(call)).rejects.toThrow("Authorizing user is not authorized for tasks:assign");
|
|
await assertUnchanged();
|
|
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.authorizer,
|
|
permissionKey: "tasks:assign_scope", scope: { agentIds: [protectedAgent.id], projectIds: [randomUUID()] } }).returning();
|
|
await expect(authority.execute(call)).rejects.toThrow("Authorizing user is not authorized for tasks:assign");
|
|
await assertUnchanged();
|
|
await db.update(principalPermissionGrants).set({ scope: { agentIds: [randomUUID()], projectIds: [project.id] } }).where(eq(principalPermissionGrants.id, grant.id));
|
|
await expect(authority.execute(call)).rejects.toThrow("Authorizing user is not authorized for tasks:assign");
|
|
await assertUnchanged();
|
|
await db.update(principalPermissionGrants).set({ scope: { agentIds: [protectedAgent.id], projectIds: [project.id] } }).where(eq(principalPermissionGrants.id, grant.id));
|
|
await expect(authority.execute(call)).resolves.toMatchObject({ disposition: "applied" });
|
|
expect(enqueueWakeup).toHaveBeenCalledTimes(1);
|
|
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
|
await expect(authority.execute(call)).rejects.toThrow("Authorizing user is not authorized for tasks:assign");
|
|
expect(enqueueWakeup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each(["enforce", "shadow"])("denies native document writes for a viewer authorizer in responsible-user %s mode", async mode => {
|
|
vi.stubEnv("PAPERCLIP_RESPONSIBLE_USER_AUTHZ_MODE", mode);
|
|
const f = await fixture(), runId = randomUUID();
|
|
await db.update(companyMemberships).set({ membershipRole: "viewer" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.authorizer)));
|
|
await db.insert(issueAccessGrants).values({ issueId: f.issue.id, subjectType: "user", subjectId: f.authorizer, source: "explicit" });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.issue.id,
|
|
responsibleUserId: f.responsible, status: "running", runtimeMode: "native", invocationSource: "assignment" });
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issue.id));
|
|
const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.agentId, issueId: f.issue.id, runId,
|
|
museRuntime: true, externalAuthorizingUserId: f.authorizer, assertBridgeAuthority: async () => {} });
|
|
const call = { tool: "write_document", callId: "viewer-write", arguments: { key: "report", title: "Report", body: "Must not be written", baseRevisionId: null, idempotencyKey: "viewer-write" } };
|
|
await expect(authority.execute(call)).rejects.toThrow("Authorizing user is not authorized for issue:mutate");
|
|
expect(await db.select({ id: issueDocuments.id }).from(issueDocuments).where(eq(issueDocuments.issueId, f.issue.id))).toEqual([]);
|
|
await db.update(companyMemberships).set({ membershipRole: "operator" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.authorizer)));
|
|
await expect(authority.execute(call)).resolves.toMatchObject({ disposition: "applied" });
|
|
await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, f.authorizer)));
|
|
await expect(authorizationService(db).decide({ actor: f.actor, action: "tasks:assign",
|
|
resource: { type: "issue", companyId: f.companyId, assigneeAgentId: f.agentId }, scope: { assigneeAgentId: f.agentId } }))
|
|
.resolves.toMatchObject({ allowed: false, reason: "deny_missing_membership" });
|
|
});
|
|
|
|
it("applies external admission before a native reviewer changes the issue execution owner", async () => {
|
|
const f = await fixture(), runId = randomUUID();
|
|
const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "queued", runtimeMode: "native",
|
|
invocationSource: "assignment", contextSnapshot: { issueId: f.issue.id, nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } }).returning();
|
|
await db.insert(externalAgentHolds).values({ companyId: f.companyId, agentId: f.agentId, provider: "muse", assignmentId: randomUUID(), bindingId: randomUUID(),
|
|
bindingGeneration: 1, runId: randomUUID(), workerUnknown: false, nativeEffectsUnknown: true });
|
|
await expect(claimQueuedNativeReviewRun(db, { run, claimedAt: new Date(), agentNameKey: "muse", claimValues: { status: "running" } }))
|
|
.rejects.toMatchObject({ status: 409, details: { code: "external_agent_overlap" } });
|
|
expect(await db.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, runId))).toEqual([{ status: "queued" }]);
|
|
expect(await db.select({ owner: issues.executionRunId }).from(issues).where(eq(issues.id, f.issue.id))).toEqual([{ owner: null }]);
|
|
});
|
|
|
|
it("advertises only first-party tools for Muse and rejects missing authorizer authority", async () => {
|
|
const f = await fixture();
|
|
const authority = new PaperclipRunnerToolAuthority(db, { companyId: f.companyId, agentId: f.agentId, issueId: f.issue.id, runId: randomUUID(), museRuntime: true, apiToolsEnabled: true, workspaceBridge: true });
|
|
const names = authority.definitions().map(tool => tool.name);
|
|
expect(names).toContain("get_identity"); expect(names).toContain("get_task_context"); expect(names).toContain("write_document");
|
|
for (const excluded of ["search_api", "call_api", "request_human_input", "connections_search", "workspace_command", "read_workspace_file", "register_deliverable", "get_workspace", "list_workspaces", "select_workspace", "prepare_repository"]) expect(names).not.toContain(excluded);
|
|
for (const tool of ["get_workspace", "list_workspaces", "select_workspace", "prepare_repository"]) {
|
|
await expect(authority.execute({ tool, callId: tool, arguments: {} })).rejects.toThrow("first-party runtime profile");
|
|
}
|
|
await expect(authority.execute({ tool: "get_task_context", callId: "test", arguments: {} })).rejects.toThrow("authorizer");
|
|
});
|
|
});
|