diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 4f16a6e4d87..9e432cbfcc0 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -129,8 +129,10 @@ stay in storage and are omitted from that read. Task and agent binding, current status, budget, and task privacy checks retain their existing predicates. Mutation transactions still lock and read complete run records. The unlocked authority read also omits named launch renderings and preparation data that its -tools do not consume. Full wake payloads, other context, and result receipts -remain available; the complete saved context stays in storage for execution. +tools do not consume. Ordinary tool checks also omit the wake payload. Mail +checks load the current full wake to enforce external-chat restrictions. Other +context and result receipts remain available. The complete saved context stays +in storage for execution. WebSocket delivery uses one fresh authorization batch for an event's task and run bindings. When both bindings name the same task, the batch reads its current diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index 2558b813555..74524c733f3 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -1,4 +1,4 @@ -import { connectorToolDefinitionsForSkills } from "../connector-runtime.js"; +import * as connectorRuntime from "../connector-runtime.js"; import * as cloudIdentity from "../cloud-runtime-identity.js"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { randomUUID } from "node:crypto"; @@ -101,7 +101,7 @@ describe("PaperclipRunnerToolAuthority", () => { }); it("registers pinned connector definitions without discovery and rejects revoked assignments at use", async () => { - const connectorAssignments = connectorToolDefinitionsForSkills(new Set([ + const connectorAssignments = connectorRuntime.connectorToolDefinitionsForSkills(new Set([ "paperclipai/paperclip/slack", "paperclipai/paperclip/browser-use-cloud", "custom/agentmail", ])); expect(connectorAssignments).toHaveLength(1); @@ -244,7 +244,7 @@ describe("PaperclipRunnerToolAuthority", () => { } }); - it("reads tool authority without transferring unused launch renderings", async () => { + it("reads tool authority without transferring unused launch renderings or wake payloads", async () => { const binding = { companyId, agentId, issueId, runId }; const authority = new PaperclipRunnerToolAuthority(db, binding); const contextCall = { tool: "get_task_context", callId: "launch-context-read", arguments: {} }; @@ -255,7 +255,7 @@ describe("PaperclipRunnerToolAuthority", () => { const context = { ...original.contextSnapshot, executionContinuation: { savedInput: payload }, paperclipTaskMarkdown: payload, paperclipTaskMarkdownCompact: payload, paperclipTaskMarkdownAssignment: payload, paperclipTaskMarkdownAssignmentCompact: payload, paperclipWorkspace: { savedInput: payload }, paperclipEnvironment: { savedInput: payload }, - paperclipContinuationSummary: payload, paperclipWakeComment: payload }; + paperclipContinuationSummary: payload, paperclipWakeComment: payload, paperclipWake: { retainedPayload: payload } }; await db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, runId)); const [expanded] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); const observed = createDb(temporary!.connectionString), events: Array> = [], now = Date.now(); @@ -280,6 +280,45 @@ describe("PaperclipRunnerToolAuthority", () => { } }); + it("checks the current full wake before mail actions while ordinary tools omit its payload", async () => { + const connectorAssignments = connectorRuntime.connectorToolDefinitionsForSkills(new Set(["paperclipai/paperclip/agentmail"])); + const name = connectorAssignments.flatMap(assignment => assignment.tools).find(tool => tool.name.startsWith("agentmail_"))!.name; + const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId, connectorAssignments }); + const [original] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + const dispatch = vi.spyOn(connectorRuntime, "executeConnectorTool").mockResolvedValue({ outcome: "mail-dispatched" }); + const wake = { + reason: "External chat message received", externalChatProvider: "github", checkedOutByHarness: true, + issue: { id: issueId, identifier: "RNT-1", title: "Conversation", description: "Task", descriptionTruncated: false, + status: "in_progress", workMode: "standard" }, + commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 }, commentIds: ["wake-comment"], + latestCommentId: "wake-comment", comments: [{ id: "wake-comment", issueId, body: "Reply here", bodyTruncated: false, authorType: "user" }], + fallbackFetchNeeded: false, + }; + const call = { tool: name, callId: "current-mail-wake", arguments: {} }; + try { + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, paperclipWake: wake } }).where(eq(heartbeatRuns.id, runId)); + await expect(authority.execute(call)).rejects.toThrow("Restricted chat runs cannot use email actions"); + expect(dispatch).not.toHaveBeenCalled(); + await expect(authority.execute({ tool: "get_task_context", callId: "chat-task-context", arguments: {} })) + .resolves.toMatchObject({ activeTask: { id: issueId } }); + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId } }).where(eq(heartbeatRuns.id, runId)); + await expect(authority.execute(call)).resolves.toEqual({ outcome: "mail-dispatched" }); + expect(dispatch).toHaveBeenCalledTimes(1); + await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, paperclipWake: { ...wake, checkedOutByHarness: false, + externalChatExecutionBound: true, issue: { ...wake.issue, status: "in_review" } } } }).where(eq(heartbeatRuns.id, runId)); + await expect(authority.execute(call)).rejects.toThrow("Restricted chat runs cannot use email actions"); + expect(dispatch).toHaveBeenCalledTimes(1); + for (const context of [{ issueId, source: "chat:github" }, { issueId, paperclipExternalChatQuestionResponse: true }]) { + await db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, runId)); + await expect(authority.execute(call)).rejects.toThrow("Restricted chat runs cannot use email actions"); + expect(dispatch).toHaveBeenCalledTimes(1); + } + } finally { + dispatch.mockRestore(); + await db.update(heartbeatRuns).set({ contextSnapshot: original.contextSnapshot }).where(eq(heartbeatRuns.id, runId)); + } + }); + it("preserves platform receipt provenance in both mixed task history readers", async () => { const at = new Date("2026-10-10T12:00:00Z"); const commentIds = Array.from({ length: 4 }, () => randomUUID()); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index e257744e545..31f65f12fcd 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -449,7 +449,7 @@ export class PaperclipRunnerToolAuthority { } if (isConnectorTool(call.tool)) { if (!(this.binding.connectorAssignments ?? []).some((assignment) => assignment.tools.some((tool) => tool.name === call.tool))) throw forbidden("Connector tool is not available to this run"); - const { run } = await this.#boundContext(); + const { run } = await this.#boundContext({ includeChatWake: call.tool.startsWith("agentmail_") }); const snapshot = record(run.contextSnapshot); if (call.tool.startsWith("agentmail_") && (isPaperclipExternalChatContractTurn(snapshot.paperclipWake) || String(snapshot.source ?? "").startsWith("chat:") || snapshot.paperclipExternalChatQuestionResponse)) throw forbidden("Restricted chat runs cannot use email actions"); return executeConnectorTool(this.db, this.binding, call.tool, call.arguments); @@ -1225,7 +1225,7 @@ export class PaperclipRunnerToolAuthority { ...(this.binding.museRuntime ? { authorizingUserId: this.binding.externalAuthorizingUserId } : {}) }; } - async #boundContext() { + async #boundContext(options: { includeChatWake?: boolean } = {}) { if (this.binding.museRuntime && !this.binding.externalAuthorizingUserId) throw forbidden("Muse authorizer is unavailable"); const [row] = await this.db.select({ issue: issues, actor: agents, run: { id: heartbeatRuns.id, @@ -1239,6 +1239,7 @@ export class PaperclipRunnerToolAuthority { then ${heartbeatRuns.contextSnapshot} - array['executionContinuation', 'paperclipTaskMarkdown', 'paperclipTaskMarkdownCompact', 'paperclipTaskMarkdownAssignment', 'paperclipTaskMarkdownAssignmentCompact', 'paperclipWorkspace', 'paperclipEnvironment', 'paperclipContinuationSummary', 'paperclipWakeComment'] + ${options.includeChatWake ? sql`` : sql`- 'paperclipWake'`} else ${heartbeatRuns.contextSnapshot} end`, resultJson: heartbeatRuns.resultJson, } })