diff --git a/doc/DATABASE.md b/doc/DATABASE.md index bf4c88a98ca..06cf24c9368 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -123,6 +123,12 @@ launch context does not travel with each policy or parent-report check. JSON types, task-key precedence, and company and agent identity checks are preserved. Each decision still reads current data; this is not an authorization cache. +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. + 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 authority once. Different task bindings must both pass. The batch is discarded 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 04a9b79a2cc..76815a76482 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 @@ -20,6 +20,7 @@ import { issues, } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture"; import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity } from "../run-identity.js"; import { documentService } from "../documents.js"; import { issueService } from "../issues.js"; @@ -198,6 +199,51 @@ describe("PaperclipRunnerToolAuthority", () => { ).rejects.toThrow("paperclip_runner_tool_not_advertised"); }); + it("reads current tool authority without transferring saved profiles or output", async () => { + const binding = { companyId, agentId, issueId, runId }; + const authority = new PaperclipRunnerToolAuthority(db, binding); + const contextCall = { tool: "get_task_context", callId: "context-read-width", arguments: {} }; + const apiCall = { tool: "search_api", callId: "api-read-width", arguments: { query: "read task" } }; + const expectedContext = await authority.execute(contextCall); + const expectedApi = await authority.execute(apiCall); + const [original] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + const [actor] = await db.select({ status: agents.status }).from(agents).where(eq(agents.id, agentId)); + const payload = "saved-profile-and-output🙂".repeat(32_768); + await db.update(heartbeatRuns).set({ + runnerProfileJson: { ...original.runnerProfileJson, retainedPayload: payload }, + stdoutExcerpt: payload, + stderrExcerpt: payload, + }).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 context = await reader.execute(contextCall), api = await reader.execute(apiCall); + expect(context).toEqual(expectedContext); + expect(api).toEqual(expectedApi); + expect(JSON.stringify([context, api])).not.toContain("saved-profile-and-output"); + 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((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]).toEqual(expanded); + await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId)); + 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(agents).set({ status: actor.status }).where(eq(agents.id, agentId)); + await db.update(heartbeatRuns).set({ + runnerProfileJson: original.runnerProfileJson, + stdoutExcerpt: original.stdoutExcerpt, + stderrExcerpt: original.stderrExcerpt, + }).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 56bccadf0b6..40b140f01b2 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1227,7 +1227,17 @@ export class PaperclipRunnerToolAuthority { 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 }) + const [row] = await this.db.select({ issue: issues, actor: agents, run: { + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + runtimeMode: heartbeatRuns.runtimeMode, + status: heartbeatRuns.status, + invocationSource: heartbeatRuns.invocationSource, + responsibleUserId: heartbeatRuns.responsibleUserId, + contextSnapshot: heartbeatRuns.contextSnapshot, + resultJson: heartbeatRuns.resultJson, + } }) .from(heartbeatRuns) .innerJoin(issues, eq(issues.id, this.binding.issueId)) .innerJoin(agents, eq(agents.id, this.binding.agentId)) @@ -2168,7 +2178,7 @@ export class PaperclipRunnerToolAuthority { }); } - async #sourcePlanApproval(context: { issue: typeof issues.$inferSelect; run: typeof heartbeatRuns.$inferSelect }) { + async #sourcePlanApproval(context: { issue: typeof issues.$inferSelect; run: Pick }) { 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");