diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 762ea37f2d9..4f16a6e4d87 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -127,7 +127,10 @@ Native tool authority lookups select current run identity, status, invocation, responsible user, context, and receipt data. Saved profiles and output excerpts 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. +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. 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 76815a76482..2558b813555 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 @@ -244,6 +244,42 @@ describe("PaperclipRunnerToolAuthority", () => { } }); + it("reads tool authority without transferring unused launch renderings", async () => { + const binding = { companyId, agentId, issueId, runId }; + const authority = new PaperclipRunnerToolAuthority(db, binding); + const contextCall = { tool: "get_task_context", callId: "launch-context-read", arguments: {} }; + const apiCall = { tool: "search_api", callId: "launch-api-read", arguments: { query: "read task" } }; + const expectedContext = await authority.execute(contextCall), expectedApi = await authority.execute(apiCall); + const [original] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + const payload = "retained-launch-rendering🙂".repeat(8192); + 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 }; + 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(); + const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event)); + installDatabaseResponseCapture(observed, { heartbeatRuns, issues, agents, companies }, capture); + try { + const reader = new PaperclipRunnerToolAuthority(observed, binding); + const actualContext = await reader.execute(contextCall), actualApi = await reader.execute(apiCall); + capture.flush(); + const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select"); + expect(reads.length).toBeGreaterThan(0); + expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(16 * 1024); + expect(actualContext).toEqual(expectedContext); + expect(actualApi).toEqual(expectedApi); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]).toEqual(expanded); + await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, runId)); + await expect(reader.execute(contextCall)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized"); + await expect(reader.execute(apiCall)).rejects.toThrow("paperclip_runner_tool_binding_not_authorized"); + } finally { + capture.flush(); await observed.$client.end(); + await db.update(heartbeatRuns).set({ contextSnapshot: original.contextSnapshot, status: original.status }).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 40b140f01b2..e257744e545 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1235,7 +1235,11 @@ export class PaperclipRunnerToolAuthority { status: heartbeatRuns.status, invocationSource: heartbeatRuns.invocationSource, responsibleUserId: heartbeatRuns.responsibleUserId, - contextSnapshot: heartbeatRuns.contextSnapshot, + contextSnapshot: sql | null>`case when jsonb_typeof(${heartbeatRuns.contextSnapshot}) = 'object' + then ${heartbeatRuns.contextSnapshot} - array['executionContinuation', 'paperclipTaskMarkdown', + 'paperclipTaskMarkdownCompact', 'paperclipTaskMarkdownAssignment', 'paperclipTaskMarkdownAssignmentCompact', + 'paperclipWorkspace', 'paperclipEnvironment', 'paperclipContinuationSummary', 'paperclipWakeComment'] + else ${heartbeatRuns.contextSnapshot} end`, resultJson: heartbeatRuns.resultJson, } }) .from(heartbeatRuns)