mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Connect personal Muse through secure native execution and Vault OAuth (#16448)
Add company-scoped Muse native control, OAuth, wake receiver and recovery. Recheck current authority after waits and before effects or receipt replay. Preserve task-read scope, assignment expiry, independent holds, nonblocking reassignment and truthful cleanup provenance. Retain source trust on progress comments and verify current Muse V8 authority through real signed-JWT HTTP requests. Preserve the upstream trimmed ordinary authentication lookup. Refs #16448. Preserve captured canonical queue recovery and concurrent environment preparation while retaining the scoped Muse V8 exclusion. Normal migration 0354 and existing Muse SQL remain unchanged. Fresh current-head Greptile review is 5/5 with the project-write finding addressed. Required CI and independent technical review gate the merge; maintainer override is reserved for formal approval rules. Recheck current Muse project-write authority after all receipt/task/run/admission waits, before creation or replay. Real PostgreSQL regressions reproduce four late effects/replays on the old route; the repaired three-file treatment passes 354 cases with no skips, and the scoped server typecheck passes. Ordinary/Dot behavior is preserved. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
63fbb714e6
commit
e6dcc6b095
72 files changed
+9775
-1021
No files matched your search
@@ -12,6 +12,7 @@ export function createLocalNativeQuestionBridge(input: {
|
||||
resolve: (input: {
|
||||
runId: string; requestId: string; turnId: string;
|
||||
resolution: HarnessRuntimeRequestResolution;
|
||||
commandId?: string;
|
||||
authorizeBeforeDispatch: () => Promise<void>;
|
||||
}) => Promise<{ commandId: string }>;
|
||||
}) {
|
||||
@@ -23,13 +24,13 @@ export function createLocalNativeQuestionBridge(input: {
|
||||
close();
|
||||
release = registerNativeQuestionCommandTarget({
|
||||
binding: input.binding,
|
||||
queueCommand: async (type, payload) => {
|
||||
queueCommand: async (type, payload, commandId) => {
|
||||
if (type !== "request.resolve" || typeof payload?.requestId !== "string") throw new Error("native_question_command_invalid");
|
||||
const requestId = payload.requestId;
|
||||
const pending = await readPendingNativeRuntimeRequest(input.db, { ...input.binding, requestId });
|
||||
if (!pending || pending.requestKind !== "runtime") throw new Error("native_question_not_pending");
|
||||
const result = await input.resolve({
|
||||
runId: input.binding.runId, requestId, turnId: pending.turnId,
|
||||
runId: input.binding.runId, requestId, turnId: pending.turnId, commandId,
|
||||
resolution: { action: "submit", response: payload.response as never },
|
||||
authorizeBeforeDispatch: async () => {
|
||||
const current = await readPendingNativeRuntimeRequest(input.db, { ...input.binding, requestId });
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { agents, agentWakeupRequests, authUsers, companies, companyMemberships, createDb,
|
||||
heartbeatRuns, issueComments, issues, museAgentBindings, museMailboxItems,
|
||||
museRunnerAssignments, museRunnerOperations } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { heartbeatService } from "../heartbeat.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { consumeMuseHistoryReceipt } from "../muse-assignment-follow-up.js";
|
||||
import { queuedCommentIdsFromWakePayload } from "../issue-queued-comment-queue.js";
|
||||
|
||||
describe("Muse native comment follow-up", () => {
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("muse-native-follow-up-");
|
||||
db = createDb(temporary.connectionString);
|
||||
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
||||
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true, enableMuse: true });
|
||||
}, 30000);
|
||||
afterAll(async () => { await temporary?.cleanup(); vi.unstubAllEnvs(); });
|
||||
async function fixture() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), userId = randomUUID(), runId = randomUUID(), bindingId = randomUUID();
|
||||
await db.insert(authUsers).values({ id: userId, name: "Muse operator", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companies).values({ id: companyId, name: "Muse follow-up", issuePrefix: `MF${companyId.slice(0, 6)}`, defaultResponsibleUserId: userId });
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Muse", status: "active", adapterType: "paperclip_runner",
|
||||
adapterConfig: { provider: "muse", museBindingId: bindingId, allowUnmeteredProvider: true, lifecycleMode: "per_turn" } });
|
||||
await db.insert(museAgentBindings).values({ id: bindingId, companyId, agentId, operatorId: userId,
|
||||
status: "ready", pairedAt: new Date(), receiverContactAt: new Date(), verifiedReplyAt: new Date() });
|
||||
const [task] = await db.insert(issues).values({ companyId, title: "Active Muse task", status: "in_progress",
|
||||
assigneeAgentId: agentId, responsibleUserId: userId }).returning();
|
||||
const sessionId = randomUUID();
|
||||
const [run] = await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", invocationSource: "assignment",
|
||||
runtimeMode: "native", nativeIssueId: task!.id, issueId: task!.id, nativeSessionId: sessionId,
|
||||
responsibleUserId: userId, contextSnapshot: { issueId: task!.id } }).returning();
|
||||
await db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, task!.id));
|
||||
const [assignment] = await db.insert(museRunnerAssignments).values({ companyId, bindingId, bindingGeneration: 1, runId, agentId,
|
||||
normalizedSessionId: sessionId, turnId: randomUUID(), controllerGeneration: 1, catalogDigest: "sha256:fixture",
|
||||
status: "accepted", projection: {}, acceptBy: new Date(Date.now() + 60000), expiresAt: new Date(Date.now() + 60000),
|
||||
claimedAt: new Date(), nativeAcceptedAt: new Date() }).returning();
|
||||
const comments = await db.insert(issueComments).values([1, 2].map(index => ({ companyId, issueId: task!.id,
|
||||
authorUserId: userId, body: `Follow-up ${index}` }))).returning();
|
||||
const wake = (commentId: string) => heartbeatService(db).wakeup(agentId, { source: "assignment", triggerDetail: "system",
|
||||
reason: "issue_commented", payload: { issueId: task!.id, commentId }, requestedByActorType: "user", requestedByActorId: userId,
|
||||
contextSnapshot: { issueId: task!.id, wakeCommentId: commentId, wakeReason: "issue_commented" } });
|
||||
async function history(commentIds: string[], failed = false) {
|
||||
const [receipt] = await db.insert(museRunnerOperations).values({ companyId, assignmentId: assignment!.id,
|
||||
requestId: randomUUID(), digest: "sha256:history", status: "settled",
|
||||
command: { action: "tool", input: { name: "get_task_history", arguments: {} } },
|
||||
outcome: { status: "completed", isError: failed, result: { comments: commentIds.map(id => ({ id })) } } }).returning();
|
||||
return receipt!;
|
||||
}
|
||||
return { companyId, agentId, run: run!, assignment: assignment!, comments, wake, history };
|
||||
}
|
||||
it("queues unread comments on the accepted turn, and consumes only an exact successful history receipt", async () => {
|
||||
const f = await fixture();
|
||||
for (const comment of [f.comments[0]!, f.comments[0]!, f.comments[1]!]) expect((await f.wake(comment.id))?.id).toBe(f.run.id);
|
||||
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, f.agentId))).toHaveLength(1);
|
||||
let wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, f.agentId));
|
||||
expect(wakes).toHaveLength(1);
|
||||
expect(wakes[0]).toMatchObject({ status: "deferred_issue_execution", runId: null, finishedAt: null });
|
||||
expect(queuedCommentIdsFromWakePayload(wakes[0]!.payload)).toEqual(f.comments.map(comment => comment.id));
|
||||
expect(await db.select().from(museMailboxItems).where(and(eq(museMailboxItems.assignmentId, f.assignment.id), eq(museMailboxItems.kind, "follow_up")))).toHaveLength(2);
|
||||
// Reading references is not consumption; the deferred wake remains durable.
|
||||
wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, f.agentId));
|
||||
expect(wakes[0]?.status).toBe("deferred_issue_execution");
|
||||
const receipt = await f.history(f.comments.map(comment => comment.id));
|
||||
await consumeMuseHistoryReceipt(db, receipt);
|
||||
await consumeMuseHistoryReceipt(db, receipt);
|
||||
expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakes[0]!.id)))[0]).toMatchObject({ status: "coalesced", runId: f.run.id });
|
||||
});
|
||||
it.each(["partial", "failed", "read before admission", "terminal"] as const)("preserves unread input at the %s boundary", async scenario => {
|
||||
const f = await fixture(), receipt = await f.history([f.comments[0]!.id], scenario === "failed");
|
||||
if (scenario === "read before admission") await consumeMuseHistoryReceipt(db, receipt);
|
||||
for (const comment of f.comments) await f.wake(comment.id);
|
||||
if (scenario === "terminal") await db.update(heartbeatRuns).set({ status: "cancelled", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.run.id));
|
||||
await consumeMuseHistoryReceipt(db, receipt);
|
||||
const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.agentId, f.agentId));
|
||||
const pending = wakes.find(wake => wake.status === "deferred_issue_execution");
|
||||
expect(pending).toBeDefined();
|
||||
expect(queuedCommentIdsFromWakePayload(pending!.payload)).toEqual(["failed", "terminal"].includes(scenario)
|
||||
? f.comments.map(comment => comment.id) : [f.comments[1]!.id]);
|
||||
if (scenario === "read before admission") expect(wakes.find(wake => wake.payload?.commentId === f.comments[0]!.id)?.status).toBe("coalesced");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,457 @@
|
||||
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}]});
|
||||
}
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { heartbeatRuns, type Db } from "@paperclipai/db";
|
||||
import { museRunnerBroker } from "../muse-runner-broker.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
/** Call only after run projection commits and outside issue/run transactions.
|
||||
* Reconcile the original binding, including after a provider configuration change.
|
||||
* The broker retains failed-worker and unsettled-native-effect uncertainty.
|
||||
*/
|
||||
export async function reconcileMuseNativeTerminalRun(db: Db, run: Pick<typeof heartbeatRuns.$inferSelect,
|
||||
"id" | "companyId" | "agentId" | "runtimeMode" | "status" | "runnerProfileJson">): Promise<void> {
|
||||
if (run.runtimeMode !== "native" || !["succeeded", "failed", "cancelled", "timed_out", "interrupted"].includes(run.status)) return;
|
||||
const execution = record(record(run.runnerProfileJson).nativeExecutionInput);
|
||||
const provider = record(execution.provider);
|
||||
const binding = record(provider.binding);
|
||||
if (execution.schema !== "paperclip.native-execution-input.v8" || provider.kind !== "muse"
|
||||
|| binding.companyId !== run.companyId || binding.agentId !== run.agentId
|
||||
|| typeof binding.bindingId !== "string" || !binding.bindingId) return;
|
||||
await museRunnerBroker(db).reconcileTerminalAssignments(run.companyId, run.agentId, binding.bindingId).catch(err => {
|
||||
logger.warn({ err, runId: run.id, bindingId: binding.bindingId }, "Muse terminal assignment reconciliation deferred to maintenance");
|
||||
});
|
||||
}
|
||||
@@ -331,7 +331,7 @@ describeEmbeddedPostgres("native question bridge", () => {
|
||||
);
|
||||
if (answered.kind !== "ask_user_questions") throw new Error("wrong question kind");
|
||||
expect(await deliverNativeQuestionResponse(db, answered)).toBe("queued");
|
||||
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ runId, requestId: "request-1", turnId: "turn-1",
|
||||
expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ runId, requestId: "request-1", turnId: "turn-1", commandId: `question_${cards[0]!.id}`,
|
||||
resolution: { action: "submit", response: { schema: "paperclip.question_response.v1", answers: { color: { selectedOptionIds: ["green"] } } } },
|
||||
}));
|
||||
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withExternalAdmissionGuard, assertNoExternalOverlap } from "../../modules/external-agents/index.js";
|
||||
import { agentWakeupRequests, heartbeatRuns, issues, type Db } from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { PgUpdateSetSource } from "drizzle-orm/pg-core";
|
||||
@@ -33,6 +34,7 @@ export async function claimQueuedNativeReviewRun(db: Db, input: {
|
||||
)).for("update");
|
||||
if (!run || run.status !== "queued" || run.wakeupRequestId !== input.run.wakeupRequestId) return null;
|
||||
if ((run.contextSnapshot as Record<string, unknown> | null)?.issueId !== issueId) return null;
|
||||
await withExternalAdmissionGuard(tx, run.companyId, run.agentId, () => assertNoExternalOverlap(tx, run.companyId, run.agentId));
|
||||
const locked = await claimNativeReviewExecutionLock(tx as unknown as Db, {
|
||||
companyId: run.companyId, issueId, agentId: run.agentId, runId: run.id,
|
||||
contextSnapshot: context, agentNameKey: input.agentNameKey, claimedAt: input.claimedAt,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reconcileMuseNativeTerminalRun } from "./muse-terminal-reconciliation.js";
|
||||
import { eligibleIssueMonitorWait } from "../issue-monitors.js";
|
||||
import { isNativePlanWaitResult, readNativePlanWait } from "./native-plan-wait.js";
|
||||
import { activeIssueInteractionCondition } from "../issue-question-context.js";
|
||||
@@ -1184,6 +1185,10 @@ export async function finalizeNativeRun(input: {
|
||||
});
|
||||
}
|
||||
await resolveCommittedFinalizationRecovery(input.db, run, coordinator.issueId);
|
||||
if (input.projectRunStatus) {
|
||||
const [projectedRun] = await input.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id));
|
||||
if (projectedRun) await reconcileMuseNativeTerminalRun(input.db, projectedRun);
|
||||
}
|
||||
return coordinator;
|
||||
}
|
||||
const [resultRow, contractRow] = await Promise.all([
|
||||
@@ -1590,6 +1595,7 @@ export async function finalizeNativeRun(input: {
|
||||
});
|
||||
}
|
||||
if (finalizationPhase === "committed") await resolveCommittedFinalizationRecovery(input.db, run, coordinator.issueId);
|
||||
if (input.projectRunStatus && updatedRun) await reconcileMuseNativeTerminalRun(input.db, updatedRun);
|
||||
return {
|
||||
...coordinator,
|
||||
phase: finalizationPhase,
|
||||
|
||||
@@ -2690,6 +2690,18 @@ describe("remote runner build metadata", () => {
|
||||
prpTransportModes: ["dial_ws_loopback", "dial_wss", "listen_ws"],
|
||||
};
|
||||
|
||||
it("accepts the current contract with the required transport", () => {
|
||||
expect(() =>
|
||||
assertRemoteRunnerBuildMetadata(current, "listen_ws"),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("requires the versioned Muse artifact capability and does not accept Dot capability as proof", () => {
|
||||
expect(() => assertRemoteRunnerBuildMetadata(current, "listen_ws", "muse_external_v1")).toThrow("runner_remote_muse_capability_missing");
|
||||
expect(() => assertRemoteRunnerBuildMetadata({ ...current, externalProviderCapabilities: ["openai_dot_mcp"] }, "listen_ws", "muse_external_v1")).toThrow("runner_remote_muse_capability_missing");
|
||||
expect(() => assertRemoteRunnerBuildMetadata({ ...current, externalProviderCapabilities: ["openai_dot_mcp", "muse_external_v1"] }, "listen_ws", "muse_external_v1")).not.toThrow();
|
||||
});
|
||||
|
||||
it("requires an explicit Dot bridge capability only for remote Dot execution", () => {
|
||||
expect(() => assertRemoteRunnerBuildMetadata(current, "listen_ws", "openai_dot_mcp"))
|
||||
.toThrow("runner_remote_dot_capability_missing");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { museRunnerBroker } from "../muse-runner-broker.js";
|
||||
import { stageRemoteLaunchAssets } from "./remote-launch-assets.js";
|
||||
import { createRunPreparationTiming } from "../execution-preparation-timing.js";
|
||||
import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js";
|
||||
@@ -1658,6 +1659,12 @@ const initializingSessionToolAuthorities = new Set<string>();
|
||||
const executingRunnerdSessionScopes = new Map<string, string>();
|
||||
const executingNativeOwnerScopes = new Map<string, symbol>();
|
||||
|
||||
async function assertExternalNativeRunAuthority(db: Db, execution: NativeExecutionInput, authorityDatabase: Db = db): Promise<void> {
|
||||
if (execution.schema === "paperclip.native-execution-input.v6") return dotRunnerBroker(db).assertRunAuthority(execution);
|
||||
if (execution.schema === "paperclip.native-execution-input.v8") return museRunnerBroker(db).assertRunAuthority(execution, authorityDatabase);
|
||||
throw new Error("external_native_execution_required");
|
||||
}
|
||||
|
||||
function nativeSessionKey(execution: NativeExecutionInput): string {
|
||||
return (
|
||||
execution.session.normalizedSessionId ??
|
||||
@@ -6566,6 +6573,7 @@ export async function resolveNativeRuntimeRequest(input: {
|
||||
requestId: string;
|
||||
turnId: string;
|
||||
resolution: HarnessRuntimeRequestResolution;
|
||||
commandId?: string;
|
||||
/**
|
||||
* Revalidate the caller's durable lifecycle and authorization immediately
|
||||
* before the provider mutation. Capability and snapshot reads above this
|
||||
@@ -6616,7 +6624,7 @@ export async function resolveNativeRuntimeRequest(input: {
|
||||
return { commandId: prior.commandId };
|
||||
}
|
||||
|
||||
const commandId = `native-runtime-response:${randomUUID()}`;
|
||||
const commandId = input.commandId ?? `native-runtime-response:${randomUUID()}`;
|
||||
// Reserve the request key before yielding to authorization or provider I/O.
|
||||
// This makes duplicate retries join one dispatch and makes a conflicting
|
||||
// response fail closed even while the first authorization check is pending.
|
||||
@@ -6632,6 +6640,7 @@ export async function resolveNativeRuntimeRequest(input: {
|
||||
requestId: input.requestId,
|
||||
turnId: input.turnId,
|
||||
resolution: input.resolution,
|
||||
commandId,
|
||||
});
|
||||
});
|
||||
const resolution: NativeRuntimeRequestResolution = {
|
||||
@@ -7569,10 +7578,10 @@ export async function executePaperclipNativeSession(input: {
|
||||
await retireSupersededWarmNativeSessions(
|
||||
ownerScope, nativeSessionScopeKey(input.execution),
|
||||
);
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v6" && isCloudManagedInstance()
|
||||
if ((input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8") && isCloudManagedInstance()
|
||||
&& (!input.useRunnerd || input.runnerExecutionTarget?.kind !== "remote"
|
||||
|| input.runnerExecutionTarget.transport !== "sandbox")) {
|
||||
throw new Error("dot_cloud_requires_managed_runner");
|
||||
throw new Error(input.execution.provider.kind === "muse" ? "muse_cloud_requires_managed_runner" : "dot_cloud_requires_managed_runner");
|
||||
}
|
||||
if (!input.useRunnerd) {
|
||||
return await executePaperclipNativeSessionWithinScope(input);
|
||||
@@ -7594,7 +7603,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
// a later sweep from closing an owner this turn is about to acquire.
|
||||
await closingWarmNativeSessions.get(sessionScopeId);
|
||||
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v6") {
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8") {
|
||||
return await executePaperclipNativeSessionWithinScope(input);
|
||||
}
|
||||
const workspaceRoot = input.execution.workspace.cwd;
|
||||
@@ -7710,7 +7719,8 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
input.execution.provider.kind !== "claude_managed" &&
|
||||
input.execution.provider.kind !== "aws_agentcore" &&
|
||||
input.execution.provider.kind !== "acpx" &&
|
||||
input.execution.schema !== "paperclip.native-execution-input.v6"
|
||||
input.execution.schema !== "paperclip.native-execution-input.v6" &&
|
||||
input.execution.schema !== "paperclip.native-execution-input.v8"
|
||||
) {
|
||||
throw new Error("paperclip_runner_provider_unsupported");
|
||||
}
|
||||
@@ -8681,7 +8691,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
});
|
||||
try {
|
||||
const prepareRunnerEnvironment = async () => {
|
||||
if (input.managedGitHub && input.execution.schema !== "paperclip.native-execution-input.v6") {
|
||||
if (input.managedGitHub && input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8") {
|
||||
if (!input.execution.workspace.cwd) throw new Error("native_workspace_required");
|
||||
githubAccess ??= await timeController("github_access", () => createNativeGitHubAccess({
|
||||
scope: input.execution.binding,
|
||||
@@ -9712,7 +9722,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
resultJson: {
|
||||
nativeResult: native.result as unknown as Record<string, unknown>,
|
||||
nativeTerminal: native.terminal as unknown as Record<string, unknown>,
|
||||
...(input.execution.schema === "paperclip.native-execution-input.v6" ? { providerAccounting: { usage: null, cost: null, externallyBilled: true }, externalStopConfirmed: false } : {}),
|
||||
...((input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8") ? { providerAccounting: { usage: null, cost: null, externallyBilled: true }, externalStopConfirmed: false } : {}),
|
||||
...(providerFailure ? { nativeProviderFailure: providerFailure.diagnostic } : {}),
|
||||
...(unclassifiedTurnFailure ? { nativeTurnFailure: unclassifiedTurnFailure } : {}),
|
||||
...(native.goalRolloverRequired ? { goalRolloverRequired: true } : {}),
|
||||
@@ -9724,7 +9734,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
...resolveNativeBilling(input.execution.provider, input.runnerEnvironment, input.billingIdentity),
|
||||
model: input.execution.provider.model,
|
||||
usage: normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" }),
|
||||
costUsd: input.execution.schema !== "paperclip.native-execution-input.v6" && normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" })
|
||||
costUsd: input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8" && normalizeNativeUsage(native.usage, { inputIncludesCacheReads: input.execution.provider.kind === "codex" })
|
||||
? nativeUsageCostUsd(native.usage, input.execution.provider) ?? null : null,
|
||||
costUsdExact: typeof native.usage?.accountingCostUsdExact === "string" ? native.usage.accountingCostUsdExact : undefined,
|
||||
costStatus: nativeAccountingComplete && native.usage?.accountingCostIncomplete !== true
|
||||
@@ -10263,7 +10273,7 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
|
||||
export function assertRemoteRunnerBuildMetadata(
|
||||
value: unknown,
|
||||
requiredMode: "dial_wss" | "listen_ws",
|
||||
requiredExternalProvider?: "openai_dot_mcp" | "grok_bot_mcp",
|
||||
requiredExternalProvider?: "openai_dot_mcp" | "grok_bot_mcp" | "muse_external_v1",
|
||||
): void {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("runner_remote_artifact_metadata_invalid");
|
||||
@@ -10282,7 +10292,7 @@ export function assertRemoteRunnerBuildMetadata(
|
||||
// Reject them before launch so preparation stages the bundled runner instead.
|
||||
if (requiredExternalProvider && (!Array.isArray(metadata.externalProviderCapabilities)
|
||||
|| !metadata.externalProviderCapabilities.includes(requiredExternalProvider))) {
|
||||
throw new Error("runner_remote_dot_capability_missing: install the current Paperclip Runner artifact");
|
||||
throw new Error(requiredExternalProvider === "muse_external_v1" ? "runner_remote_muse_capability_missing: install the current Paperclip Runner artifact" : "runner_remote_dot_capability_missing: install the current Paperclip Runner artifact");
|
||||
}
|
||||
const sessionCapabilities = Array.isArray(metadata.durableSessionCapabilities)
|
||||
? metadata.durableSessionCapabilities
|
||||
@@ -11342,13 +11352,16 @@ export async function createRunnerdBackend(input: {
|
||||
|
||||
/** Dot uses the same managed process launcher and authenticated PRP transports.
|
||||
* Its execution contract still exposes no workspace, shell, or provider secrets. */
|
||||
async function prepareRemoteDotRunner({ input, target, runner, root, identity }: {
|
||||
async function prepareRemoteExternalRunner({ input, target, runner, root, identity }: {
|
||||
input: Parameters<typeof createRunnerdBackend>[0];
|
||||
target: Extract<AdapterExecutionTarget, { kind: "remote" }>;
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
root: string;
|
||||
identity: NonNullable<NonNullable<Parameters<typeof createNativeSessionBackend>[1]>["dotRunnerOptions"]>["identity"];
|
||||
}) {
|
||||
const muse = input.execution.provider.kind === "muse";
|
||||
const checkpointFile = muse ? "muse-provider-state.json" : "dot-provider-state.json";
|
||||
const checkpointSchema = muse ? "paperclip.runner.muse-provider-state.v1" : "paperclip.runner.dot-provider-state.v1";
|
||||
const runnerBinary = input.runnerRemoteBinaryPath?.trim() || resolvePaperclipRunnerBinary();
|
||||
const remoteRoot = posix.join(target.remoteCwd, ".paperclip-runtime", "paperclip-runner");
|
||||
const remoteBinary = posix.join(remoteRoot, "bin", "paperclip-runnerd");
|
||||
@@ -11370,7 +11383,7 @@ async function prepareRemoteDotRunner({ input, target, runner, root, identity }:
|
||||
}
|
||||
const metadata = await runner.execute({ command: remoteBinary, args: ["--build-metadata"], bypassSession: true, timeoutMs: 30000 });
|
||||
if (metadata.exitCode !== 0 || metadata.timedOut) throw new Error("runner_remote_artifact_verification_failed");
|
||||
assertRemoteRunnerBuildMetadata(JSON.parse(metadata.stdout), requiredMode, input.execution.provider.kind === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp");
|
||||
assertRemoteRunnerBuildMetadata(JSON.parse(metadata.stdout), requiredMode, muse ? "muse_external_v1" : input.execution.provider.kind === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp");
|
||||
const checksum = await runner.execute({ command: "sh", args: ["-c",
|
||||
'if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', "paperclip-dot-artifact", remoteBinary],
|
||||
bypassSession: true, timeoutMs: 10000 });
|
||||
@@ -11381,16 +11394,16 @@ async function prepareRemoteDotRunner({ input, target, runner, root, identity }:
|
||||
const readProviderState = async () => {
|
||||
const result = await runner.execute({ command: "sh", args: ["-c",
|
||||
'set -eu; if test ! -e "$1" && test ! -L "$1"; then exit 3; fi; test -f "$1" && test ! -L "$1"; test "$(wc -c < "$1")" -le 33554432; base64 < "$1"',
|
||||
"paperclip-dot-checkpoint", posix.join(runnerStateDirectory, "dot-provider-state.json")], bypassSession: true, timeoutMs: 10000 });
|
||||
"paperclip-dot-checkpoint", posix.join(runnerStateDirectory, checkpointFile)], bypassSession: true, timeoutMs: 10000 });
|
||||
if (result.exitCode === 3 && !result.timedOut) return null;
|
||||
if (result.exitCode !== 0 || result.timedOut) throw new Error("dot_provider_checkpoint_unavailable");
|
||||
const state = record(JSON.parse(Buffer.from(result.stdout.replace(/\s+/g, ""), "base64").toString("utf8")));
|
||||
if (state.schema !== "paperclip.runner.dot-provider-state.v1" || state.runId !== identity.runId
|
||||
if (state.schema !== checkpointSchema || state.runId !== identity.runId
|
||||
|| state.sessionId !== identity.normalizedSessionId || state.turnId !== identity.turnId) throw new Error("dot_runner_checkpoint_authority_mismatch");
|
||||
// Retain inspectable recovery evidence on the controller without changing
|
||||
// which execution target is authoritative for replay.
|
||||
mkdirSync(join(root, "runner"), { recursive: true, mode: 0o700 });
|
||||
const saved = join(root, "runner", "dot-provider-state.json");
|
||||
const saved = join(root, "runner", checkpointFile);
|
||||
const temporary = `${saved}.${randomUUID()}.tmp`;
|
||||
writeFileSync(temporary, JSON.stringify(state), { mode: 0o600 });
|
||||
renameSync(temporary, saved);
|
||||
@@ -11444,9 +11457,6 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
retainedTransition?: VerifiedWarmTransitionBinding,
|
||||
): Promise<NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession }> {
|
||||
const timeController = createRunPreparationTiming(input.execution.binding.runId, "native.controller");
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v8") {
|
||||
throw new Error("paperclip_runner_provider_unsupported");
|
||||
}
|
||||
let recoveryPending = retainedTransition !== undefined;
|
||||
const target = input.runnerExecutionTarget ?? { kind: "local" as const };
|
||||
const remoteTarget = target.kind === "remote" ? target : null;
|
||||
@@ -11468,7 +11478,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
input.execution.binding,
|
||||
));
|
||||
const pinnedSkills = new Set("runtimeContext" in input.execution ? input.execution.runtimeContext.skills.map((skill) => skill.key) : []);
|
||||
const connectorAssignments = connectorToolDefinitionsForSkills(pinnedSkills);
|
||||
const connectorAssignments = input.execution.provider.kind !== "muse" ? connectorToolDefinitionsForSkills(pinnedSkills) : [];
|
||||
const reviewRun = await input.db.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, input.execution.binding.runId),
|
||||
@@ -11533,7 +11543,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
taskAttachmentRead: dotAttachmentActor?.config.dotAttachmentAccess === true,
|
||||
dotRuntime: input.execution.schema === "paperclip.native-execution-input.v6",
|
||||
firstPartyOnly: input.execution.provider.kind === "grok_bot",
|
||||
assertBridgeAuthority: input.execution.schema === "paperclip.native-execution-input.v6" ? () => dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6) : undefined,
|
||||
museRuntime: input.execution.provider.kind === "muse",
|
||||
externalAuthorizingUserId: input.execution.provider.kind === "muse"
|
||||
? await museRunnerBroker(input.db).authorizingUserIdForBinding(input.execution.provider.binding) : undefined,
|
||||
assertBridgeAuthority: input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8" ? (database = input.db) => assertExternalNativeRunAuthority(input.db, input.execution, database) : undefined,
|
||||
workspaceRoot: input.dotWorkspaceRoot ?? remoteTarget?.remoteCwd ?? input.execution.workspace.cwd ?? undefined,
|
||||
executionTargetKind: target.kind,
|
||||
readRemoteWorkspaceFile: remoteTarget && remoteCommandRunner
|
||||
@@ -11573,34 +11586,42 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
input.durableEnvironmentLeaseId ??
|
||||
input.execution.binding.executionWorkspaceId;
|
||||
mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v6") {
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8") {
|
||||
if (target.kind === "remote" && input.dotWorkspaceRoot) throw new Error("dot_remote_workspace_access_unavailable");
|
||||
const remoteDot = target.kind === "remote"
|
||||
? await prepareRemoteDotRunner({ input, target, runner: remoteCommandRunner!, root,
|
||||
? await prepareRemoteExternalRunner({ input, target, runner: remoteCommandRunner!, root,
|
||||
identity: { runnerInstanceId: effectiveRunnerInstanceId, environmentLeaseId: effectiveEnvironmentLeaseId,
|
||||
runId: input.execution.binding.runId, normalizedSessionId: nativeSessionKey(input.execution),
|
||||
turnId: `turn-${input.execution.binding.runId}`, itemId: `item-${input.execution.binding.runId}` } })
|
||||
: null;
|
||||
const recoveredProcess = input.restartRecovery?.kind === "reattach_existing_runner" ? input.restartRecovery.process : null;
|
||||
const backend = createNativeSessionBackend(input.execution, {
|
||||
onSpawn: input.onSpawn, dynamicTools,
|
||||
dynamicToolHandler: async call => { await dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6); return authorityEpoch.execute(call); },
|
||||
completionFeedback: async result => { await dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6); await timeController("tool_authority", () => authorityEpoch.definitions()); return nativeCompletionFeedback(input.db, input.execution.binding.runId, result); },
|
||||
dotRunnerOptions: {
|
||||
const externalExecution = input.execution;
|
||||
const externalOptions = {
|
||||
stateDirectory: root, runnerBinary: remoteDot?.runnerBinary ?? resolvePaperclipRunnerBinary(),
|
||||
...(remoteDot ?? {}),
|
||||
identity: { runnerInstanceId: effectiveRunnerInstanceId, environmentLeaseId: effectiveEnvironmentLeaseId,
|
||||
runId: input.execution.binding.runId, normalizedSessionId: nativeSessionKey(input.execution),
|
||||
turnId: `turn-${input.execution.binding.runId}`, itemId: `item-${input.execution.binding.runId}` },
|
||||
port: dotRunnerBroker(input.db).port(input.execution),
|
||||
controlPlaneRegistration: remoteDot?.controlPlaneRegistration ?? createNativeRunnerExecutionDriver({
|
||||
target, binding: input.execution.binding, trace: input.trace, onLog: input.onLog,
|
||||
}).controlPlaneRegistration,
|
||||
adoptExistingRunner: remoteDot?.adoptExistingRunner ?? (recoveredProcess ? { ...recoveredProcess,
|
||||
isAlive: () => verifiedRecoveryProcessIsAlive(recoveredProcess),
|
||||
signal: signal => signalVerifiedRecoveryProcess(recoveredProcess, signal) } : undefined),
|
||||
signal: (signal: NodeJS.Signals = "SIGTERM") => signalVerifiedRecoveryProcess(recoveredProcess, signal) } : undefined),
|
||||
};
|
||||
const backendOptions = {
|
||||
onSpawn: input.onSpawn, dynamicTools,
|
||||
dynamicToolHandler: async (call: Parameters<SessionToolAuthorityEpoch["execute"]>[0]) => {
|
||||
await assertExternalNativeRunAuthority(input.db, externalExecution); return authorityEpoch.execute(call);
|
||||
},
|
||||
});
|
||||
completionFeedback: async (result: PrpStructuredRunResult) => {
|
||||
await assertExternalNativeRunAuthority(input.db, externalExecution); await timeController("tool_authority", () => authorityEpoch.definitions());
|
||||
return nativeCompletionFeedback(input.db, externalExecution.binding.runId, result);
|
||||
},
|
||||
};
|
||||
const backend = externalExecution.schema === "paperclip.native-execution-input.v8"
|
||||
? createNativeSessionBackend(externalExecution, { ...backendOptions, museRunnerOptions: { ...externalOptions, port: museRunnerBroker(input.db).port(externalExecution) } })
|
||||
: createNativeSessionBackend(externalExecution, { ...backendOptions, dotRunnerOptions: { ...externalOptions, port: dotRunnerBroker(input.db).port(externalExecution) } });
|
||||
const prior = sessionToolAuthorityEpochs.get(sessionScopeId); if (prior) prior.revoke();
|
||||
sessionToolAuthorityEpochs.set(sessionScopeId, authorityEpoch);
|
||||
return { ...backend, descriptor: () => backend.descriptor(), openSession: input => backend.openSession(input),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
|
||||
import { skillAccessService } from "../skill-access.js";
|
||||
import { withExternalExecutionAdmission, type ExternalAdmissionTransaction } from "../../modules/external-agents/index.js";
|
||||
import { callTaskWorkspaceTool } from "../task-workspace-tools.js";
|
||||
import { TaskAttachmentReadCache, TASK_ATTACHMENT_DEFINITIONS, TASK_ATTACHMENT_SCHEMAS, listTaskAttachments, readTaskAttachment } from "./runner-task-attachments.js";
|
||||
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool, readWorkspaceUploadFile, settleRunnerBridgeRead, assertRunnerWorkspacePathAllowed } from "./runner-bridge-tools.js";
|
||||
@@ -22,7 +23,7 @@ import { assertAssignableAgent } from "../agent-assignability.js";
|
||||
import { approvalReadSqlCondition, canActorReadApproval, authorizationService, issueReadSqlCondition, type AuthorizationActor } from "../authorization.js";
|
||||
import { resolveCoreTrustPreset } from "../trust-preset-resolver.js";
|
||||
import { normalizeIssueExecutionPolicy, redactIssueMonitorExternalRef } from "../issue-execution-policy.js";
|
||||
import { buildLowTrustSourceTrust } from "../source-trust.js";
|
||||
import { buildLowTrustSourceTrust, isLowTrustQuarantined, LOW_TRUST_QUARANTINED_BODY, resolveActorSourceTrustForIssue, sanitizeQuarantinedCommentForHigherTrust } from "../source-trust.js";
|
||||
import { handoffPlanContext } from "./handoff-plan-context.js";
|
||||
import { callCreateSkillTool, callUpdateSkillTool } from "../skill-tools.js";
|
||||
import { callProjectTool } from "../project-tools.js";
|
||||
@@ -52,7 +53,7 @@ import { badRequest, forbidden, notFound, HttpError } from "../../errors.js";
|
||||
import { runnerApiCatalog, searchRunnerApi } from "./runner-api-catalog.js";
|
||||
import { executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiFile } from "./runner-api-client.js";
|
||||
import { acquireRunnerApiResponseSlot, runnerApiCompanyCaptureMaxBytes, RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES, RunnerApiResponseLimitError } from "./runner-api-response-limits.js";
|
||||
import { and, desc, eq, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNull, notInArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
authUsers,
|
||||
@@ -123,10 +124,22 @@ const IMPLEMENTED_OPERATIONS = new Set([
|
||||
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
|
||||
]);
|
||||
|
||||
/** Closed first-party native Muse profile. No connector, generic API, credential or workspace grants. */
|
||||
export const MUSE_NATIVE_OPERATIONS = new Set([
|
||||
"get_identity", "list_people",
|
||||
"submit_complaint", "submit_suggestion", "get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title", "set_task_monitor",
|
||||
"create_task", "reassign_task", "set_dependencies", "create_project", "list_projects", "list_documents", "read_document", "list_document_revisions", "write_document",
|
||||
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context", "read_current_wake_comments", "resolve_review",
|
||||
]);
|
||||
|
||||
const NATIVE_REVIEW_READ_TOOLS = new Set([
|
||||
"get_task_context", "get_task_history", "list_documents", "read_document", "list_document_revisions",
|
||||
]);
|
||||
|
||||
type NativeIssueDocument = NonNullable<Awaited<ReturnType<ReturnType<typeof documentService>["getIssueDocumentByKey"]>>>;
|
||||
type NativeDocumentContent = { id: string | null; documentId: string; body: string; changeSummary?: string | null };
|
||||
const UNKNOWN_DOCUMENT_REVISION_BODY = "[Document revision content omitted: historical source trust could not be established.]";
|
||||
|
||||
type Binding = {
|
||||
/** Server-derived scope for one addressed native completion review. */
|
||||
nativeReview?: NativeReviewAssignmentContext;
|
||||
@@ -148,12 +161,14 @@ type Binding = {
|
||||
workspaceBridge?: boolean;
|
||||
/** Pinned provider identity; configuration edits cannot remove API file guards. */
|
||||
dotRuntime?: boolean;
|
||||
museRuntime?: boolean;
|
||||
externalAuthorizingUserId?: string;
|
||||
/** Grok retains its provider apps; this release exposes first-party Paperclip tools only. */
|
||||
firstPartyOnly?: boolean;
|
||||
/** Operator-approved Dot attachment access, pinned when the turn opens. */
|
||||
taskAttachmentRead?: boolean;
|
||||
runtimeContext?: NativeRuntimeContextSnapshot;
|
||||
assertBridgeAuthority?: () => Promise<void>;
|
||||
assertBridgeAuthority?: (database?: Db) => Promise<void>;
|
||||
executionTargetKind?: "local" | "remote";
|
||||
readRemoteWorkspaceFile?: RemoteWorkspaceFileReader;
|
||||
currentWakeComments?: CurrentWakeCommentsBinding;
|
||||
@@ -299,7 +314,8 @@ export class PaperclipRunnerToolAuthority {
|
||||
...(this.binding.connectorAssignments ?? []).flatMap((assignment) => assignment.tools)];
|
||||
const assignedTools = this.binding.firstPartyOnly ? [] : this.binding.assignedMcpTools?.definitions((tools) =>
|
||||
runnerCodexDynamicToolsFit([...connectionTools, ...tools, ...definitions])) ?? [];
|
||||
return [...connectionTools, ...assignedTools, ...definitions];
|
||||
const all = [...connectionTools, ...assignedTools, ...definitions];
|
||||
return this.binding.museRuntime ? all.filter(definition => MUSE_NATIVE_OPERATIONS.has(String(definition.name))) : all;
|
||||
}
|
||||
|
||||
async execute(call: {
|
||||
@@ -307,6 +323,10 @@ export class PaperclipRunnerToolAuthority {
|
||||
callId: string;
|
||||
arguments: unknown;
|
||||
}): Promise<unknown> {
|
||||
if (this.binding.museRuntime) {
|
||||
await this.binding.assertBridgeAuthority?.();
|
||||
if (!MUSE_NATIVE_OPERATIONS.has(call.tool)) throw forbidden("This operation is outside the Muse first-party runtime profile");
|
||||
}
|
||||
if (this.binding.firstPartyOnly && (EXTERNAL_WORKSPACE_OPERATIONS.has(call.tool) || call.tool.startsWith("workspace_"))) throw forbidden("Paperclip workspace access is unavailable to this Grok Bot session.");
|
||||
if (this.binding.nativeReview) {
|
||||
if (call.tool === "resolve_review") return this.#resolveReview(call.arguments);
|
||||
@@ -537,7 +557,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
case "select_workspace":
|
||||
case "prepare_repository": {
|
||||
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
|
||||
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
|
||||
const token = this.#apiToken(context);
|
||||
if (!apiUrl || !token) throw new Error("Workspace tool authentication is unavailable");
|
||||
return callTaskWorkspaceTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, workspaceRoot: this.binding.workspaceRoot });
|
||||
}
|
||||
@@ -593,6 +613,9 @@ export class PaperclipRunnerToolAuthority {
|
||||
))
|
||||
.orderBy(desc(issues.createdAt), desc(issues.id))
|
||||
.limit(101);
|
||||
const readableChildren = this.binding.museRuntime
|
||||
? await this.#readableMuseTaskIds(context.run, childTasks.map(task => task.id)) : null;
|
||||
const visibleChildTasks = readableChildren ? childTasks.filter(task => readableChildren.has(task.id)) : childTasks;
|
||||
return {
|
||||
company: { id: this.binding.companyId },
|
||||
actor: redactedActor(context.actor),
|
||||
@@ -600,7 +623,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
...this.binding, conversationMode: isConversation(context.issue),
|
||||
}),
|
||||
activeTask: redactedTask(context.issue),
|
||||
childTasks: childTasks.slice(0, 100).map(redactedTask),
|
||||
childTasks: visibleChildTasks.slice(0, 100).map(redactedTask),
|
||||
childTasksTruncated: childTasks.length > 100,
|
||||
delegationGuidance: "Reuse existing child tasks for delegated work. Inspect a completed child's comments and documents before creating another task for the same deliverable. Use search_tasks if the child task list is truncated.",
|
||||
run: {
|
||||
@@ -609,9 +632,9 @@ export class PaperclipRunnerToolAuthority {
|
||||
invocationSource: context.run.invocationSource,
|
||||
},
|
||||
connectionGuidance: CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
acceptedPlan: await this.#acceptedPlan(context.run.contextSnapshot),
|
||||
sourcePlanApproval: await handoffPlanContext(this.db, context.issue, this.#privacyActor(context.run)),
|
||||
childReviewOutcomes: await childReviewOutcomes(this.db, this.binding.companyId, this.binding.issueId, this.#privacyActor(context.run)),
|
||||
acceptedPlan: await this.#acceptedPlan(context.run.contextSnapshot, context.issue),
|
||||
sourcePlanApproval: await this.#sourcePlanApproval(context),
|
||||
childReviewOutcomes: await this.#readableChildReviewOutcomes(context.run),
|
||||
...(this.binding.nativeReview ? {
|
||||
assignedReview: (await getNativeReviewAssignment(this.db, {
|
||||
...this.binding, contextSnapshot: this.binding.nativeReview,
|
||||
@@ -621,11 +644,14 @@ export class PaperclipRunnerToolAuthority {
|
||||
};
|
||||
}
|
||||
case "get_task_history": {
|
||||
const readerSourceTrust = await resolveActorSourceTrustForIssue({ db: this.db, issue: context.issue,
|
||||
actor: { actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId } });
|
||||
const limit = boundedLimit(input.limit);
|
||||
const comments = await this.db.select({
|
||||
id: issueComments.id,
|
||||
body: issueComments.body,
|
||||
origin: issueComments.origin,
|
||||
sourceTrust: issueComments.sourceTrust,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
@@ -637,26 +663,40 @@ export class PaperclipRunnerToolAuthority {
|
||||
))
|
||||
.orderBy(desc(issueComments.createdAt))
|
||||
.limit(limit);
|
||||
return { comments: comments.reverse().map(comment => ({ ...comment, body: fastResponseHistoryBody(comment) })) };
|
||||
return { comments: comments.reverse().map(comment => {
|
||||
const visible = isLowTrustQuarantined(readerSourceTrust) ? comment : sanitizeQuarantinedCommentForHigherTrust(comment);
|
||||
return { ...visible, body: fastResponseHistoryBody(visible) };
|
||||
}) };
|
||||
}
|
||||
case "search_tasks": {
|
||||
const tasks = await issueService(this.db).list(this.binding.companyId, { readCondition: await issueReadSqlCondition(this.db, this.#privacyActor(context.run)) });
|
||||
const query = typeof input.query === "string" ? input.query.toLowerCase() : "";
|
||||
const statuses = Array.isArray(input.statuses) ? new Set(input.statuses.filter((value): value is string => typeof value === "string")) : null;
|
||||
return { tasks: tasks.filter((task) =>
|
||||
const matching = tasks.filter((task) =>
|
||||
(!query || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query))
|
||||
&& (!statuses || statuses.size === 0 || statuses.has(task.status))
|
||||
).slice(0, boundedLimit(input.limit)).map(redactedTask) };
|
||||
);
|
||||
const readable = this.binding.museRuntime ? await this.#readableMuseTaskIds(context.run, matching.map(task => task.id)) : null;
|
||||
return { tasks: matching.filter(task => !readable || readable.has(task.id))
|
||||
.slice(0, boundedLimit(input.limit)).map(redactedTask) };
|
||||
}
|
||||
case "list_documents":
|
||||
return { documents: await documentService(this.db).listIssueDocuments(this.binding.issueId) };
|
||||
return { documents: await this.#visibleMuseDocuments(context.issue,
|
||||
await documentService(this.db).listIssueDocuments(this.binding.issueId)) };
|
||||
case "read_document": {
|
||||
const document = await documentService(this.db).getIssueDocumentByKey(this.binding.issueId, requiredString(input.key));
|
||||
if (!document) throw new Error("paperclip_runner_document_not_found");
|
||||
return { document };
|
||||
const [visible] = await this.#visibleMuseDocuments(context.issue, [document]);
|
||||
return { document: visible };
|
||||
}
|
||||
case "list_document_revisions": {
|
||||
const key = requiredString(input.key);
|
||||
const service = documentService(this.db);
|
||||
const revisions = await service.listIssueDocumentRevisions(this.binding.issueId, key);
|
||||
if (!this.binding.museRuntime || revisions.length === 0) return { revisions };
|
||||
const document = await service.getIssueDocumentByKey(this.binding.issueId, key);
|
||||
return { revisions: await this.#projectMuseDocumentContent(context.issue, revisions, document ? [document] : []) };
|
||||
}
|
||||
case "list_document_revisions":
|
||||
return { revisions: await documentService(this.db).listIssueDocumentRevisions(this.binding.issueId, requiredString(input.key)) };
|
||||
case "write_document": return this.#writeDocument(input);
|
||||
case "list_agents":
|
||||
return { actors: (await agentService(this.db).list(this.binding.companyId)).map(redactedActor) };
|
||||
@@ -681,7 +721,8 @@ export class PaperclipRunnerToolAuthority {
|
||||
eq(issues.companyId, this.binding.companyId),
|
||||
await issueReadSqlCondition(this.db, this.#privacyActor(context.run)),
|
||||
));
|
||||
return { approval, tasks: tasks.map((row) => row.issue) };
|
||||
const readable = this.binding.museRuntime ? await this.#readableMuseTaskIds(context.run, tasks.map(row => row.issue.id)) : null;
|
||||
return { approval, tasks: tasks.filter(row => !readable || readable.has(row.issue.id)).map(row => row.issue) };
|
||||
}
|
||||
case "report_progress": return this.#reportProgress(input);
|
||||
case "request_human_input": return this.#requestHumanInput(input,
|
||||
@@ -745,7 +786,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
run: { responsibleUserId: string | null; contextSnapshot: unknown };
|
||||
}) {
|
||||
return createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType,
|
||||
this.binding.runId, context.run.responsibleUserId, this.#apiScope(context));
|
||||
this.binding.runId, context.run.responsibleUserId, this.#apiScope(context), this.binding.externalAuthorizingUserId);
|
||||
}
|
||||
|
||||
async #callApi(callId: string, value: unknown): Promise<unknown> {
|
||||
@@ -1019,12 +1060,32 @@ export class PaperclipRunnerToolAuthority {
|
||||
return approval;
|
||||
}
|
||||
|
||||
async #readableMuseTaskIds(run: { responsibleUserId: string | null }, taskIds: string[]): Promise<Set<string>> {
|
||||
const ids = [...new Set(taskIds)];
|
||||
if (!ids.length) return new Set();
|
||||
const decisions = await authorizationService(this.db).decideReads(this.#privacyActor(run),
|
||||
ids.map(issueId => ({ type: "issue", companyId: this.binding.companyId, issueId })));
|
||||
return new Set(ids.filter((_id, index) => decisions[index]?.allowed));
|
||||
}
|
||||
|
||||
async #readableChildReviewOutcomes(run: { responsibleUserId: string | null }) {
|
||||
const outcomes = await childReviewOutcomes(this.db, this.binding.companyId, this.binding.issueId, this.#privacyActor(run));
|
||||
if (!this.binding.museRuntime) return outcomes;
|
||||
const taskId = (outcome: (typeof outcomes)[number]) => record(outcome.result).childTaskId;
|
||||
const readable = await this.#readableMuseTaskIds(run, outcomes.flatMap(outcome => {
|
||||
const id = taskId(outcome); return typeof id === "string" ? [id] : [];
|
||||
}));
|
||||
return outcomes.filter(outcome => { const id = taskId(outcome); return typeof id === "string" && readable.has(id); });
|
||||
}
|
||||
|
||||
#privacyActor(run: { responsibleUserId: string | null }): AuthorizationActor {
|
||||
return { type: "agent", agentId: this.binding.agentId, companyId: this.binding.companyId,
|
||||
runId: this.binding.runId, source: "agent_jwt", onBehalfOfUserId: run.responsibleUserId };
|
||||
runId: this.binding.runId, source: "agent_jwt", onBehalfOfUserId: run.responsibleUserId,
|
||||
...(this.binding.museRuntime ? { authorizingUserId: this.binding.externalAuthorizingUserId } : {}) };
|
||||
}
|
||||
|
||||
async #boundContext() {
|
||||
if (this.binding.museRuntime && !this.binding.externalAuthorizingUserId) throw forbidden("Muse authorizer is unavailable");
|
||||
const [row] = await this.db.select({ issue: issues, actor: agents, run: heartbeatRuns })
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(issues, eq(issues.id, this.binding.issueId))
|
||||
@@ -1116,11 +1177,13 @@ export class PaperclipRunnerToolAuthority {
|
||||
idempotencyKey,
|
||||
input,
|
||||
async (tx, context) => {
|
||||
const sourceTrust = this.binding.museRuntime ? await resolveActorSourceTrustForIssue({ db: tx, issue: context.issue,
|
||||
actor: { actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId } }) : undefined;
|
||||
const comment = await issueService(tx).addComment(
|
||||
this.binding.issueId,
|
||||
body,
|
||||
{ agentId: this.binding.agentId, runId: this.binding.runId },
|
||||
{ authorizationReason: "paperclip_runner_protocol" },
|
||||
{ authorizationReason: "paperclip_runner_protocol", sourceTrust },
|
||||
tx,
|
||||
);
|
||||
const result = { commentId: comment.id, issueId: this.binding.issueId, disposition: "applied" };
|
||||
@@ -1154,7 +1217,9 @@ export class PaperclipRunnerToolAuthority {
|
||||
async #writeDocument(input: Record<string, unknown>): Promise<unknown> {
|
||||
const idempotencyKey = requiredString(input.idempotencyKey);
|
||||
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
|
||||
const result = await this.#withMutationReceipt("write_document", idempotencyKey, input, async (tx) => {
|
||||
const result = await this.#withMutationReceipt("write_document", idempotencyKey, input, async (tx, context) => {
|
||||
const sourceTrust = this.binding.museRuntime ? await resolveActorSourceTrustForIssue({ db: tx, issue: context.issue,
|
||||
actor: { actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId } }) : undefined;
|
||||
const write = await documentService(tx).upsertIssueDocument({
|
||||
issueId: this.binding.issueId,
|
||||
key: requiredString(input.key),
|
||||
@@ -1167,6 +1232,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
: requiredString(input.changeSummary),
|
||||
createdByAgentId: this.binding.agentId,
|
||||
createdByRunId: this.binding.runId,
|
||||
sourceTrust,
|
||||
});
|
||||
const activity = await persistActivity(tx, {
|
||||
companyId: this.binding.companyId,
|
||||
@@ -1240,10 +1306,14 @@ export class PaperclipRunnerToolAuthority {
|
||||
}) => {
|
||||
const parentIssueId = context.issue.conversationAgentId ? null : context.issue.id;
|
||||
const projectId = nullableProviderId(input.projectId) ?? (parentIssueId ? context.issue.projectId : null);
|
||||
if (this.binding.museRuntime && projectId) {
|
||||
const access = await authorizationService(tx).decide({ actor: this.#privacyActor(context.run), action: "project:read",
|
||||
resource: { type: "project", companyId: this.binding.companyId, projectId } });
|
||||
if (!access.allowed) throw forbidden("Project is unavailable to this Muse run");
|
||||
}
|
||||
const scope = { projectId, parentIssueId, assigneeAgentId, assigneeUserId };
|
||||
const decision = await authorizationService(tx).decide({
|
||||
actor: { type: "agent", source: "agent_jwt", companyId: this.binding.companyId,
|
||||
agentId: this.binding.agentId, runId: this.binding.runId, onBehalfOfUserId: context.run.responsibleUserId },
|
||||
actor: this.#privacyActor(context.run),
|
||||
action: "tasks:assign",
|
||||
resource: { type: "issue", companyId: this.binding.companyId, ...scope },
|
||||
scope,
|
||||
@@ -1436,16 +1506,24 @@ export class PaperclipRunnerToolAuthority {
|
||||
return record(prior.details?.receipt);
|
||||
};
|
||||
const authorize = async (tx: Db, run: typeof heartbeatRuns.$inferSelect) => {
|
||||
const [target] = await tx.select().from(issues).where(and(eq(issues.id, taskId), eq(issues.companyId, this.binding.companyId))).for("update");
|
||||
// Muse idle comments lock this target before admission. Never wait for
|
||||
// their target while holding the caller's admission lock.
|
||||
const [target] = await tx.select().from(issues).where(and(
|
||||
eq(issues.id, taskId), eq(issues.companyId, this.binding.companyId),
|
||||
)).for("update", this.binding.museRuntime ? { noWait: true } : undefined).catch((error: unknown) => {
|
||||
if (this.binding.museRuntime && (record(error).code === "55P03" || record(record(error).cause).code === "55P03")) {
|
||||
throw new HttpError(409, "paperclip_runner_reassignment_conflict: target is busy; retry after current work settles");
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (!target) throw new Error("paperclip_runner_task_not_found");
|
||||
const actor = { type: "agent" as const, source: "agent_jwt" as const, companyId: this.binding.companyId,
|
||||
agentId: this.binding.agentId, runId: this.binding.runId, onBehalfOfUserId: run.responsibleUserId };
|
||||
const actor = this.#privacyActor(run);
|
||||
for (const action of ["issue:read", "tasks:assign"] as const) {
|
||||
const decision = await authorizationService(tx).decide({ actor, action, resource: {
|
||||
type: "issue", companyId: this.binding.companyId, issueId: taskId, projectId: target.projectId,
|
||||
parentIssueId: target.parentId, assigneeAgentId: action === "tasks:assign" ? assigneeAgentId : target.assigneeAgentId,
|
||||
assigneeUserId: action === "tasks:assign" ? assigneeUserId : target.assigneeUserId, status: target.status,
|
||||
} });
|
||||
}, scope: { issueId: taskId, projectId: target.projectId, parentIssueId: target.parentId, assigneeAgentId, assigneeUserId } });
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
}
|
||||
if (assigneeAgentId) await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" });
|
||||
@@ -1681,7 +1759,12 @@ export class PaperclipRunnerToolAuthority {
|
||||
throw new Error("paperclip_runner_tool_input_invalid");
|
||||
}
|
||||
const blockedByIssueIds = input.blockedByTaskIds.map(requiredString);
|
||||
return this.#withMutationReceipt("set_dependencies", idempotencyKey, input, async (tx) => {
|
||||
return this.#withMutationReceipt("set_dependencies", idempotencyKey, input, async (tx, context) => {
|
||||
if (this.binding.museRuntime) for (const issueId of blockedByIssueIds) {
|
||||
const decision = await authorizationService(tx).decide({ actor: this.#privacyActor(context.run), action: "issue:read",
|
||||
resource: { type: "issue", companyId: this.binding.companyId, issueId } });
|
||||
if (!decision.allowed) throw forbidden("Dependency task is unavailable");
|
||||
}
|
||||
const updated = await issueService(tx).update(this.binding.issueId, {
|
||||
blockedByIssueIds,
|
||||
actorAgentId: this.binding.agentId,
|
||||
@@ -1874,7 +1957,80 @@ export class PaperclipRunnerToolAuthority {
|
||||
return result;
|
||||
}
|
||||
|
||||
async #acceptedPlan(contextSnapshot: unknown): Promise<{
|
||||
async #visibleMuseDocuments(issue: typeof issues.$inferSelect, documents: NativeIssueDocument[]) {
|
||||
if (!this.binding.museRuntime) return documents;
|
||||
const visible = await this.#projectMuseDocumentContent(issue, documents.map(document => ({
|
||||
id: document.latestRevisionId, documentId: document.id, body: document.body ?? "",
|
||||
})), documents);
|
||||
return documents.map((document, index) => ({ ...document, body: visible[index]!.body }));
|
||||
}
|
||||
|
||||
async #projectMuseDocumentContent<T extends NativeDocumentContent>(
|
||||
readerIssue: typeof issues.$inferSelect,
|
||||
entries: T[],
|
||||
currentDocuments: NativeIssueDocument[],
|
||||
sourceIssueId = this.binding.issueId,
|
||||
): Promise<T[]> {
|
||||
if (!this.binding.museRuntime || entries.length === 0) return entries;
|
||||
const readerSourceTrust = await resolveActorSourceTrustForIssue({ db: this.db, issue: readerIssue,
|
||||
actor: { actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId } });
|
||||
if (isLowTrustQuarantined(readerSourceTrust)) return entries;
|
||||
const ids = entries.flatMap(entry => entry.id ? [entry.id] : []);
|
||||
const provenance = ids.length === 0 ? [] : await this.db.select({
|
||||
id: documentRevisions.id, documentId: documentRevisions.documentId,
|
||||
createdByAgentId: documentRevisions.createdByAgentId, createdByUserId: documentRevisions.createdByUserId,
|
||||
createdByRunId: documentRevisions.createdByRunId, userId: authUsers.id, writerCompanyId: agents.companyId,
|
||||
runId: heartbeatRuns.id, runCompanyId: heartbeatRuns.companyId, runAgentId: heartbeatRuns.agentId,
|
||||
runIssueId: heartbeatRuns.nativeIssueId, runExecutionPolicy: sql<unknown>`${heartbeatRuns.contextSnapshot}->'executionPolicy'`,
|
||||
}).from(documentRevisions)
|
||||
.leftJoin(authUsers, eq(authUsers.id, documentRevisions.createdByUserId))
|
||||
.leftJoin(agents, and(eq(agents.id, documentRevisions.createdByAgentId), eq(agents.companyId, this.binding.companyId)))
|
||||
.leftJoin(heartbeatRuns, and(eq(heartbeatRuns.id, documentRevisions.createdByRunId), eq(heartbeatRuns.companyId, this.binding.companyId)))
|
||||
.where(and(eq(documentRevisions.companyId, this.binding.companyId), inArray(documentRevisions.id, ids)));
|
||||
const byRevision = new Map(provenance.map(row => [row.id, row]));
|
||||
const byDocument = new Map(currentDocuments.filter(document => document.companyId === this.binding.companyId
|
||||
&& document.issueId === sourceIssueId).map(document => [document.id, document]));
|
||||
const withhold = (entry: T, body: string): T => ({ ...entry, body,
|
||||
...("changeSummary" in entry ? { changeSummary: null } : {}) });
|
||||
return entries.map(entry => {
|
||||
const document = byDocument.get(entry.documentId);
|
||||
const original = entry.id ? byRevision.get(entry.id) : undefined;
|
||||
if (!document || !original || original.documentId !== document.id) return withhold(entry, UNKNOWN_DOCUMENT_REVISION_BODY);
|
||||
const current = document.latestRevisionId === entry.id;
|
||||
if (current && isLowTrustQuarantined(document.sourceTrust)) return withhold(entry, LOW_TRUST_QUARANTINED_BODY);
|
||||
if (current && document.sourceTrust?.disposition === "promoted"
|
||||
&& document.sourceTrust.promotedFrom?.artifactKind === "document"
|
||||
&& document.sourceTrust.promotedFrom.artifactId === document.id) return entry;
|
||||
if (original.createdByUserId && original.userId === original.createdByUserId
|
||||
&& !original.createdByAgentId && !original.createdByRunId) return entry;
|
||||
if (!original.createdByAgentId || !original.createdByRunId || original.runId !== original.createdByRunId
|
||||
|| original.writerCompanyId !== this.binding.companyId || original.runCompanyId !== this.binding.companyId
|
||||
|| original.runAgentId !== original.createdByAgentId
|
||||
|| original.runIssueId !== sourceIssueId) return withhold(entry, UNKNOWN_DOCUMENT_REVISION_BODY);
|
||||
const policy = original.runExecutionPolicy;
|
||||
if (policy !== null && policy !== undefined && (typeof policy !== "object" || Array.isArray(policy))) {
|
||||
return withhold(entry, UNKNOWN_DOCUMENT_REVISION_BODY);
|
||||
}
|
||||
const trust = resolveCoreTrustPreset({ companyId: this.binding.companyId,
|
||||
run: { companyId: original.runCompanyId, executionPolicy: policy } });
|
||||
if (trust.kind === "low_trust_review") return withhold(entry, LOW_TRUST_QUARANTINED_BODY);
|
||||
if (trust.kind === "standard" && ((current && document.sourceTrust === null)
|
||||
|| trust.sourcePresets.run === trust.preset)) return entry;
|
||||
return withhold(entry, UNKNOWN_DOCUMENT_REVISION_BODY);
|
||||
});
|
||||
}
|
||||
|
||||
async #sourcePlanApproval(context: { issue: typeof issues.$inferSelect; run: typeof heartbeatRuns.$inferSelect }) {
|
||||
const plan = await handoffPlanContext(this.db, context.issue, this.#privacyActor(context.run));
|
||||
if (!plan || !this.binding.museRuntime) return plan;
|
||||
const document = await documentService(this.db).getIssueDocumentByKey(plan.sourceIssueId, "plan");
|
||||
const [visible] = await this.#projectMuseDocumentContent(context.issue, [{
|
||||
id: plan.revisionId, documentId: document?.id ?? "", body: plan.markdown,
|
||||
}], document ? [document] : [], plan.sourceIssueId);
|
||||
return { ...plan, markdown: visible!.body };
|
||||
}
|
||||
|
||||
async #acceptedPlan(contextSnapshot: unknown, issue: typeof issues.$inferSelect): Promise<{
|
||||
documentId: string;
|
||||
revisionId: string;
|
||||
revisionNumber: number;
|
||||
@@ -1907,7 +2063,12 @@ export class PaperclipRunnerToolAuthority {
|
||||
eq(documentRevisions.companyId, this.binding.companyId),
|
||||
))
|
||||
.limit(1);
|
||||
return revision ?? null;
|
||||
if (!revision || !this.binding.museRuntime) return revision ?? null;
|
||||
const document = await documentService(this.db).getIssueDocumentByKey(this.binding.issueId, "plan");
|
||||
const [visible] = await this.#projectMuseDocumentContent(issue, [{
|
||||
id: revision.revisionId, documentId: revision.documentId, body: revision.markdown,
|
||||
}], document ? [document] : []);
|
||||
return { ...revision, markdown: visible!.body };
|
||||
}
|
||||
|
||||
async #latestAcceptedPlanRevisionId(): Promise<string | null> {
|
||||
@@ -2060,7 +2221,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<unknown> {
|
||||
return this.db.transaction(async (tx) => {
|
||||
const mutate = async (tx: ExternalAdmissionTransaction) => {
|
||||
try {
|
||||
const context = await this.#lockAuthorizedMutationContext(
|
||||
tx as unknown as Db,
|
||||
@@ -2151,6 +2312,12 @@ export class PaperclipRunnerToolAuthority {
|
||||
await options.onDefinitePreCommitFailure?.().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
return this.db.transaction(async tx => {
|
||||
if (operationId === "create_task") {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`issue-privacy-tree:${this.binding.companyId}`}, 0))`);
|
||||
}
|
||||
return mutate(tx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2159,6 +2326,10 @@ export class PaperclipRunnerToolAuthority {
|
||||
issue: typeof issues.$inferSelect;
|
||||
actor: typeof agents.$inferSelect;
|
||||
}> {
|
||||
if (this.binding.museRuntime) {
|
||||
await withExternalExecutionAdmission(tx as unknown as ExternalAdmissionTransaction,
|
||||
this.binding.companyId, this.binding.agentId, this.binding.runId, async () => {});
|
||||
}
|
||||
// Match identity activation and queue mutations before locking the run.
|
||||
await tx.select({ id: issues.id }).from(issues).where(and(
|
||||
eq(issues.id, this.binding.issueId), eq(issues.companyId, this.binding.companyId),
|
||||
@@ -2200,6 +2371,16 @@ export class PaperclipRunnerToolAuthority {
|
||||
) {
|
||||
throw new Error("paperclip_runner_tool_binding_not_authorized");
|
||||
}
|
||||
if (this.binding.museRuntime) {
|
||||
const decision = await authorizationService(tx).decide({
|
||||
actor: this.#privacyActor(context.run), action: "issue:mutate",
|
||||
resource: { type: "issue", companyId: this.binding.companyId, issueId: context.issue.id,
|
||||
projectId: context.issue.projectId, parentIssueId: context.issue.parentId,
|
||||
status: context.issue.status, assigneeAgentId: context.issue.assigneeAgentId, assigneeUserId: context.issue.assigneeUserId },
|
||||
});
|
||||
if (!decision.allowed) throw forbidden(decision.explanation);
|
||||
await this.binding.assertBridgeAuthority?.(tx);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export type QualifiedPaperclipRunnerAcpxAgent =
|
||||
type AdmittedPaperclipRunnerAcpxAgent = QualifiedPaperclipRunnerAcpxAgent | AcpxQualificationCandidate;
|
||||
|
||||
export type PaperclipRunnerProviderProfile =
|
||||
| { provider: "muse"; backend: "muse_external"; model: null; museBindingId: string }
|
||||
| { provider: "grok_bot"; backend: "grok_bot_mcp"; model: null; externalBindingId: string }
|
||||
| { provider: "openai_dot"; backend: "openai_dot_mcp"; model: null; dotBindingId: string }
|
||||
| {
|
||||
@@ -72,6 +73,7 @@ export type PaperclipRunnerProviderProfile =
|
||||
};
|
||||
|
||||
export type PaperclipRunnerNativeProviderInput =
|
||||
| { provider: "muse"; model: null; museBinding: import("../../vendor/paperclip-runner/index.js").MuseBindingSnapshot }
|
||||
| { provider: "grok_bot"; model: null; dotBinding: import("../../vendor/paperclip-runner/index.js").DotBindingSnapshot }
|
||||
| { provider: "openai_dot"; model: null; dotBinding: import("../../vendor/paperclip-runner/index.js").DotBindingSnapshot }
|
||||
| {
|
||||
@@ -169,6 +171,17 @@ export function validatePaperclipRunnerDotConfig(config: Record<string, unknown>
|
||||
return bindingId;
|
||||
}
|
||||
|
||||
/** Closed personal runtime configuration; model and workspace grants are excluded. */
|
||||
export function validatePaperclipRunnerMuseConfig(config: Record<string, unknown>, requireBinding = true): string | null {
|
||||
const bindingId = optionalString(config.museBindingId);
|
||||
if ((requireBinding && !bindingId) || (bindingId && !/^[0-9a-f-]{36}$/i.test(bindingId))
|
||||
|| config.allowUnmeteredProvider !== true || (config.lifecycleMode && config.lifecycleMode !== "per_turn")
|
||||
|| optionalString(config.model) || config.dotWorkspaceAccess === true || config.dotAttachmentAccess === true) {
|
||||
throw new PaperclipRunnerProviderProfileError("paperclip_runner_muse_config_invalid", "Muse requires a paired personal-agent binding, per-turn lifecycle and unavailable-usage acknowledgement.");
|
||||
}
|
||||
return bindingId;
|
||||
}
|
||||
|
||||
export function validatePaperclipRunnerGrokConfig(config: Record<string, unknown>, requireBinding = true): string | null {
|
||||
const bindingId = optionalString(config.externalBindingId);
|
||||
if ((requireBinding && !bindingId) || (bindingId && !/^[0-9a-f-]{36}$/i.test(bindingId))
|
||||
@@ -385,6 +398,10 @@ export function resolvePaperclipRunnerProviderProfile(
|
||||
);
|
||||
}
|
||||
|
||||
if (candidate === "muse") {
|
||||
const bindingId = validatePaperclipRunnerMuseConfig(config)!;
|
||||
return { provider: "muse", backend: "muse_external", model: null, museBindingId: bindingId };
|
||||
}
|
||||
if (candidate === "grok_bot") {
|
||||
return { provider: "grok_bot", backend: "grok_bot_mcp", model: null, externalBindingId: validatePaperclipRunnerGrokConfig(config)! };
|
||||
}
|
||||
@@ -547,6 +564,7 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
adapterConfig: unknown;
|
||||
/** Verified image CLI version for a fresh remote Codex run only. */
|
||||
codexCliVersion?: string | null;
|
||||
museBinding?: import("../../vendor/paperclip-runner/index.js").MuseBindingSnapshot;
|
||||
dotBinding?: import("../../vendor/paperclip-runner/index.js").DotBindingSnapshot;
|
||||
managedProfile?: {
|
||||
id: string;
|
||||
@@ -585,6 +603,10 @@ export function resolvePaperclipRunnerNativeProviderInput(input: {
|
||||
"Paperclip Runner provider changed after this run selected its native backend.",
|
||||
);
|
||||
}
|
||||
if (profile.provider === "muse") {
|
||||
if (!input.museBinding || input.museBinding.bindingId !== profile.museBindingId) throw new PaperclipRunnerProviderProfileError("paperclip_runner_muse_binding_unavailable", "Muse binding must be resolved by the server after normal admission.");
|
||||
return { provider: "muse", model: null, museBinding: input.museBinding };
|
||||
}
|
||||
if (profile.provider === "grok_bot") {
|
||||
if (!input.dotBinding || input.dotBinding.bindingId !== profile.externalBindingId) throw new PaperclipRunnerProviderProfileError("paperclip_runner_grok_binding_unavailable", "The Grok binding must be resolved by the server after normal admission.");
|
||||
return { provider: "grok_bot", model: null, dotBinding: input.dotBinding };
|
||||
|
||||
@@ -502,3 +502,18 @@ describe("resolveHeartbeatRuntimeMode compatibility", () => {
|
||||
})).toThrow(NativeRunnerSelectionError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("Muse native selection", () => {
|
||||
const muse = { ...eligible, museEnabled: true, adapterConfig: { provider: "muse", museBindingId: "00000000-0000-4000-8000-000000000001", allowUnmeteredProvider: true, lifecycleMode: "per_turn" } };
|
||||
it("requires both Runner and Muse rollout gates for fresh work", () => {
|
||||
expect(() => resolveNativeRuntimeMode({ ...muse, museEnabled: false })).toThrow("Enable Muse");
|
||||
expect(() => resolveNativeRuntimeMode({ ...muse, enabled: false })).toThrow("disabled");
|
||||
expect(resolveNativeRuntimeMode(muse)).toMatchObject({ kind: "native", profile: { backend: "muse_external" } });
|
||||
});
|
||||
it("rejects model, workspace and inherited Dot attachment grants", () => {
|
||||
for (const extra of [{ model: "local-model" }, { dotWorkspaceAccess: true }, { dotAttachmentAccess: true }, { lifecycleMode: "warm" }]) {
|
||||
expect(() => resolveNativeRuntimeMode({ ...muse, adapterConfig: { ...muse.adapterConfig, ...extra } })).toThrow("Muse requires");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,9 @@ export type NativeRuntimeResolution =
|
||||
| "claude_managed_agents_api"
|
||||
| "aws_agentcore_harness_api"
|
||||
| "acpx_runtime"
|
||||
| "openai_dot_mcp" | "grok_bot_mcp";
|
||||
| "openai_dot_mcp"
|
||||
| "grok_bot_mcp"
|
||||
| "muse_external";
|
||||
protocolVersion: 1;
|
||||
};
|
||||
authorityDecision: NativeStatusDecision;
|
||||
@@ -97,6 +99,7 @@ function ineligible(
|
||||
|
||||
export function resolveNativeRuntimeMode(input: {
|
||||
enabled: boolean;
|
||||
museEnabled?: boolean;
|
||||
dotEnabled?: boolean;
|
||||
grokEnabled?: boolean;
|
||||
runtimeConfig: unknown;
|
||||
@@ -127,7 +130,11 @@ export function resolveNativeRuntimeMode(input: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (runnerProfile.provider === "openai_dot") {
|
||||
// Dot has its own rollout; enabling it does not opt in other Runner providers.
|
||||
if (runnerProfile.provider === "muse") {
|
||||
if (!input.enabled) throw ineligible("paperclip_runner_rollout_disabled", "Paperclip Runner is experimental and disabled on this instance.");
|
||||
if (input.museEnabled !== true) throw ineligible("paperclip_runner_muse_disabled", "Enable Muse personal-agent connections in experimental settings before assigning new work.");
|
||||
} else if (runnerProfile.provider === "openai_dot") {
|
||||
if (input.dotEnabled !== true) throw ineligible("paperclip_runner_dot_disabled", "Enable OpenAI Dot and Assistant connections (MCP) in experimental settings before assigning new work.");
|
||||
} else if (runnerProfile.provider === "grok_bot" && (input.grokEnabled !== true || !input.enabled)) {
|
||||
throw ineligible("paperclip_runner_grok_disabled", "Enable Grok Bot, Paperclip Runner, and Assistant connections (MCP) before assigning new work.");
|
||||
@@ -188,6 +195,7 @@ export function resolveHeartbeatRuntimeMode(input: {
|
||||
runtimeModeResolvedAt: Date | null;
|
||||
};
|
||||
enabled: boolean;
|
||||
museEnabled?: boolean;
|
||||
dotEnabled?: boolean;
|
||||
grokEnabled?: boolean;
|
||||
adapterType: string | null;
|
||||
@@ -216,6 +224,7 @@ export function resolveHeartbeatRuntimeMode(input: {
|
||||
try {
|
||||
resolution = resolveNativeRuntimeMode({
|
||||
enabled: input.enabled,
|
||||
museEnabled: input.museEnabled,
|
||||
dotEnabled: input.dotEnabled,
|
||||
grokEnabled: input.grokEnabled,
|
||||
runtimeConfig: {},
|
||||
@@ -253,6 +262,7 @@ export function resolveHeartbeatRuntimeMode(input: {
|
||||
? "claude_managed"
|
||||
: resolution.profile.backend === "aws_agentcore_harness_api"
|
||||
? "aws_agentcore"
|
||||
: resolution.profile.backend === "muse_external" ? "muse"
|
||||
: resolution.profile.backend === "grok_bot_mcp" ? "grok_bot"
|
||||
: resolution.profile.backend === "openai_dot_mcp" ? "openai_dot"
|
||||
: resolution.profile.backend === "acpx_runtime"
|
||||
@@ -274,6 +284,7 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
|
||||
driverKind?: string | null;
|
||||
};
|
||||
enabled: boolean;
|
||||
museEnabled?: boolean;
|
||||
dotEnabled?: boolean;
|
||||
grokEnabled?: boolean;
|
||||
runtimeConfig: unknown;
|
||||
@@ -307,6 +318,7 @@ export function resolveHeartbeatNativeRuntimeMode(input: {
|
||||
? "claude_managed_agents_api"
|
||||
: driverKind === "aws_agentcore_harness_api"
|
||||
? "aws_agentcore_harness_api"
|
||||
: driverKind === "muse_external" ? "muse_external"
|
||||
: driverKind === "grok_bot_mcp" ? "grok_bot_mcp"
|
||||
: driverKind === "openai_dot_mcp" ? "openai_dot_mcp"
|
||||
: driverKind === "acpx_runtime"
|
||||
|
||||
Reference in new issue
Block a user