diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts index c0f274b08e4..932260c5640 100644 --- a/server/src/services/native-runtime/native-session-resume.test.ts +++ b/server/src/services/native-runtime/native-session-resume.test.ts @@ -1773,6 +1773,13 @@ describe("rebindNativeSessionCheckpoint", () => { retainedFingerprint: "sha256:f12fe3b1bf5d63b7954d8ab16f57764874806ab49acff7c6582c4de007f4ed4d", }, + { + contract: "regressed conditional run-scoped readers", + // Deployed v17 threads were opened without read_current_wake_comments + // and read_chat_attachment on ordinary turns; resume cannot add them. + retainedFingerprint: + "sha256:dc495475fb6a4bd034c6dbb2bbe0cf52e7ffc1635838889dc5f7d7251273f119", + }, { contract: "task-bound human-input description", // Deployed v9 still advertises the generic mock-task question description. @@ -1876,6 +1883,17 @@ describe("rebindNativeSessionCheckpoint", () => { executionTargetKind: "remote", }), ).not.toBeNull(); + // Deployed v17 remote threads were opened without the run-scoped readers. + expect( + rebindNativeSessionCheckpoint({ + previousRun: previousRun({ + nativeToolContractFingerprint: + "sha256:96dde421aab01f847468d1a8f12863d32dfdb3038a9aca61e1e6bdc81022d5a1", + }), + currentExecution: execution(currentRunId), + executionTargetKind: "remote", + }), + ).toBeNull(); }); diff --git a/server/src/services/native-runtime/native-session-resume.ts b/server/src/services/native-runtime/native-session-resume.ts index e3ea827cbdf..a271601ce0b 100644 --- a/server/src/services/native-runtime/native-session-resume.ts +++ b/server/src/services/native-runtime/native-session-resume.ts @@ -34,17 +34,17 @@ export function nativeToolContractFingerprintForTarget( return `sha256:${createHash("sha256") .update( JSON.stringify({ - schema: "paperclip.native-tool-contract.v17", + schema: "paperclip.native-tool-contract.v18", executionTargetKind, advertisementPolicy: { // Direct provider threads retain declarations from thread/start. // Keep this explicit so changing a tool from conditional to stable // advertisement rotates checkpoints even when its schema is unchanged. - readCurrentWakeComments: "always_advertised_binding_gated.v1", + readCurrentWakeComments: "always_advertised_binding_gated.v2", historicalChatAttachments: "always_advertised_conversation_binding_gated.v1", registerDeliverable: "verified_local_or_remote_workspace.v2", - readChatAttachment: "always_advertised_run_scope_local_staging.v1", + readChatAttachment: "always_advertised_run_scope_local_staging.v2", structuredHumanInput: "always_advertised_run_issue_agent_binding_gated_current_task_description.v2", semanticCompletion: "finish_accessible_deliverable_evidence.v4", 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 74524c733f3..247f15532a3 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 @@ -28,7 +28,8 @@ import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority. import { PaperclipRunnerSemanticAuthority } from "./runner-semantic-authority.js"; import { createAssignedMcpTools } from "./assigned-mcp-tools.js"; import type { ToolGatewayService } from "../tool-gateway.js"; -import { READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js"; +import { CURRENT_WAKE_COMMENTS_BINDING_SCHEMA, READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js"; +import { NativeChatAttachmentReadScope, READ_CHAT_ATTACHMENT_TOOL_NAME } from "./chat-attachment-read.js"; import { buildLowTrustSourceTrust, buildPromotedSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../source-trust.js"; import { CAPABILITY_SEMANTIC_TOOL_CATALOG, runnerCodexDynamicToolsFit } from "../../vendor/paperclip-runner/index.js"; @@ -119,12 +120,10 @@ describe("PaperclipRunnerToolAuthority", () => { issueId, runId, }); - expect(authority.definitions()).toHaveLength(48); + expect(authority.definitions()).toHaveLength(50); expect(authority.definitions().map(tool => tool.name)).toEqual(expect.arrayContaining([ "get_workspace", "list_workspaces", "select_workspace", "prepare_repository", ])); - expect(authority.definitions().map(tool => tool.name)).not.toContain("read_chat_attachment"); - expect(authority.definitions().map(tool => tool.name)).not.toContain("read_current_wake_comments"); const questions = authority.definitions().find(tool => tool.name === "request_human_input")!; expect(questions.description).toContain("ask only the next unanswered question"); expect(questions.description).toContain("Never fabricate answers"); @@ -197,6 +196,39 @@ describe("PaperclipRunnerToolAuthority", () => { arguments: {}, }), ).rejects.toThrow("paperclip_runner_tool_not_advertised"); + await expect( + authority.execute({ + tool: READ_CHAT_ATTACHMENT_TOOL_NAME, + callId: "attachment-read-without-run-scope", + arguments: { sourceCommentId: randomUUID(), attachmentId: randomUUID() }, + }), + ).rejects.toThrow("paperclip_runner_chat_attachment_read_scope_unavailable"); + }); + + it("keeps run-scoped reader declarations stable for resumed provider threads", () => { + // Codex thread/resume has no dynamicTools parameter: a resumed thread keeps + // the catalog from its original thread/start. A truncated chat wake that + // resumes a thread opened on an ordinary turn still needs both readers. + const ordinary = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId }); + const chatBinding = { companyId, agentId, issueId, runId }; + const truncatedChat = new PaperclipRunnerToolAuthority(db, { + ...chatBinding, + currentWakeComments: { + schema: CURRENT_WAKE_COMMENTS_BINDING_SCHEMA, + ...chatBinding, + provider: "github", + commentIds: [randomUUID()], + attachmentOmissions: [], + bindingDigest: "binding-digest", + }, + chatAttachmentReadScope: new NativeChatAttachmentReadScope({ + db, binding: chatBinding, workspaceRoot: "/workspace", executionTargetKind: "local", + }), + }); + expect(ordinary.definitions().map(tool => tool.name)).toEqual(expect.arrayContaining([ + READ_CURRENT_WAKE_COMMENTS_TOOL_NAME, READ_CHAT_ATTACHMENT_TOOL_NAME, + ])); + expect(ordinary.definitions()).toEqual(truncatedChat.definitions()); }); it("reads current tool authority without transferring saved profiles or output", async () => { 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 cc535813f77..aa6863205fb 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -315,12 +315,13 @@ export class PaperclipRunnerToolAuthority { : descriptor.inputSchema, })); // Keep the provider session's direct tool catalog stable across ordinary - // and truncated external-chat turns. Execution still fails closed unless - // this exact run carries a server-verified current-wake binding. - if (this.binding.currentWakeComments) definitions.push(READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION); + // and truncated external-chat turns: Codex thread/resume cannot add tools, + // so a reader missing at thread/start stays missing. Execution still fails + // closed unless this exact run carries the server-verified binding/scope. + definitions.push(READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION); definitions.push(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION); definitions.push(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION); - if (this.binding.chatAttachmentReadScope) definitions.push(READ_CHAT_ATTACHMENT_TOOL_DEFINITION); + definitions.push(READ_CHAT_ATTACHMENT_TOOL_DEFINITION); if (this.binding.taskAttachmentRead) definitions.push(...TASK_ATTACHMENT_DEFINITIONS); definitions.push(...runnerBridgeDefinitions({ workspace: !this.binding.firstPartyOnly && !!this.binding.workspaceRoot && this.binding.workspaceBridge === true, skills: !!this.binding.runtimeContext, api: runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled), mode: workMode })); const connectionTools = this.binding.firstPartyOnly ? [] : [...RUNTIME_CONNECTION_TOOL_DEFINITIONS,