diff --git a/packages/adapters/openclaw-gateway/src/index.ts b/packages/adapters/openclaw-gateway/src/index.ts index 137cd340e5..ea012bfa06 100644 --- a/packages/adapters/openclaw-gateway/src/index.ts +++ b/packages/adapters/openclaw-gateway/src/index.ts @@ -42,11 +42,9 @@ Session routing fields: - sessionKeyStrategy (string, optional): issue (default), fixed, or run - sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip) -Standard outbound payload additions: -- paperclip (object): standardized Paperclip context added to every gateway agent request -- paperclip.workspace (object, optional): resolved execution workspace for this run -- paperclip.workspaces (array, optional): additional workspace hints Paperclip exposed to the run -- paperclip.workspaceRuntime (object, optional): reserved workspace runtime metadata when explicitly supplied outside normal heartbeat execution +Wake payload notes: +- Paperclip wake context is embedded into the generated message text +- No top-level paperclip field is sent; the gateway agent schema rejects unknown root params Standard result metadata supported: - meta.runtimeServices (array, optional): normalized adapter-managed runtime service reports diff --git a/packages/adapters/openclaw-gateway/src/server/execute.test.ts b/packages/adapters/openclaw-gateway/src/server/execute.test.ts index 0b18108c7a..316b81c7d5 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.test.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { resolveSessionKey } from "./execute.js"; +import { buildAgentParams, resolveSessionKey } from "./execute.js"; describe("resolveSessionKey", () => { it("prefixes run-scoped session keys with the configured agent", () => { @@ -50,3 +50,51 @@ describe("resolveSessionKey", () => { ).toBe("agent:meridian:paperclip"); }); }); + +describe("buildAgentParams", () => { + it("strips root-level paperclip fields from gateway agent params", () => { + expect( + buildAgentParams({ + payloadTemplate: { + text: "old text", + paperclip: { stale: true }, + keep: "value", + }, + message: "wake text", + sessionKey: "agent:meridian:paperclip:issue:issue-456", + runId: "run-123", + configuredAgentId: "meridian", + waitTimeoutMs: 30_000, + }), + ).toEqual({ + keep: "value", + message: "wake text", + sessionKey: "agent:meridian:paperclip:issue:issue-456", + idempotencyKey: "run-123", + agentId: "meridian", + timeout: 30_000, + }); + }); + + it("preserves an explicit agentId and timeout from the payload template", () => { + expect( + buildAgentParams({ + payloadTemplate: { + agentId: "template-agent", + timeout: 5_000, + }, + message: "wake text", + sessionKey: "paperclip", + runId: "run-123", + configuredAgentId: "configured-agent", + waitTimeoutMs: 30_000, + }), + ).toEqual({ + agentId: "template-agent", + timeout: 5_000, + message: "wake text", + sessionKey: "paperclip", + idempotencyKey: "run-123", + }); + }); +}); diff --git a/packages/adapters/openclaw-gateway/src/server/execute.ts b/packages/adapters/openclaw-gateway/src/server/execute.ts index d5caabd253..570f420850 100644 --- a/packages/adapters/openclaw-gateway/src/server/execute.ts +++ b/packages/adapters/openclaw-gateway/src/server/execute.ts @@ -470,60 +470,32 @@ function joinWakePayloadSections(structuredWakePrompt: string, structuredWakeJso return sections.join("\n"); } -function buildStandardPaperclipPayload( - ctx: AdapterExecutionContext, - wakePayload: WakePayload, - paperclipEnv: Record, - payloadTemplate: Record, -): Record { - const templatePaperclip = parseObject(payloadTemplate.paperclip); - const workspace = asRecord(ctx.context.paperclipWorkspace); - const workspaces = Array.isArray(ctx.context.paperclipWorkspaces) - ? ctx.context.paperclipWorkspaces.filter((entry): entry is Record => Boolean(asRecord(entry))) - : []; - const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime); - const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents) - ? ctx.context.paperclipRuntimeServiceIntents.filter( - (entry): entry is Record => Boolean(asRecord(entry)), - ) - : []; - - const standardPaperclip: Record = { - runId: ctx.runId, - companyId: ctx.agent.companyId, - agentId: ctx.agent.id, - agentName: ctx.agent.name, - taskId: wakePayload.taskId, - issueId: wakePayload.issueId, - issueIds: wakePayload.issueIds, - wakeReason: wakePayload.wakeReason, - wakeCommentId: wakePayload.wakeCommentId, - approvalId: wakePayload.approvalId, - approvalStatus: wakePayload.approvalStatus, - apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null, +export function buildAgentParams(input: { + payloadTemplate: Record; + message: string; + sessionKey: string; + runId: string; + configuredAgentId: string | null; + waitTimeoutMs: number; +}): Record { + const agentParams: Record = { + ...input.payloadTemplate, + message: input.message, + sessionKey: input.sessionKey, + idempotencyKey: input.runId, }; - const structuredWake = parseObject(ctx.context.paperclipWake); - if (Object.keys(structuredWake).length > 0) { - standardPaperclip.wake = structuredWake; + delete agentParams.text; + delete agentParams.paperclip; + + if (input.configuredAgentId && !nonEmpty(agentParams.agentId)) { + agentParams.agentId = input.configuredAgentId; } - if (workspace) { - standardPaperclip.workspace = workspace; - } - if (workspaces.length > 0) { - standardPaperclip.workspaces = workspaces; - } - if (runtimeServiceIntents.length > 0 || Object.keys(configuredWorkspaceRuntime).length > 0) { - standardPaperclip.workspaceRuntime = { - ...configuredWorkspaceRuntime, - ...(runtimeServiceIntents.length > 0 ? { services: runtimeServiceIntents } : {}), - }; + if (typeof agentParams.timeout !== "number") { + agentParams.timeout = input.waitTimeoutMs; } - return { - ...templatePaperclip, - ...standardPaperclip, - }; + return agentParams; } function normalizeUrl(input: string): URL | null { @@ -1135,24 +1107,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...payloadTemplate, + const agentParams = buildAgentParams({ + payloadTemplate, message, sessionKey, - idempotencyKey: ctx.runId, - }; - delete agentParams.text; - agentParams.paperclip = paperclipPayload; - - if (configuredAgentId && !nonEmpty(agentParams.agentId)) { - agentParams.agentId = configuredAgentId; - } - - if (typeof agentParams.timeout !== "number") { - agentParams.timeout = waitTimeoutMs; - } + runId: ctx.runId, + configuredAgentId, + waitTimeoutMs, + }); if (ctx.onMeta) { await ctx.onMeta({ diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index a883a37009..1a137cbf06 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -19,6 +19,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.ts"; +import { parseWakePayloadFromMessage } from "./helpers/wake-message.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -476,11 +477,11 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { return statusesByRunId.get(firstRun!.id) === "succeeded" && statusesByRunId.get(secondRunId) === "succeeded"; }, 90_000); - expect(secondPayload.paperclip).toMatchObject({ - wake: { - commentIds: [comment2.id, comment3.id], - latestCommentId: comment3.id, - }, + expect(secondPayload.paperclip).toBeUndefined(); + const secondWake = parseWakePayloadFromMessage(secondPayload.message); + expect(secondWake).toMatchObject({ + commentIds: [comment2.id, comment3.id], + latestCommentId: comment3.id, }); expect(String(secondPayload.message ?? "")).toContain("Second comment"); expect(String(secondPayload.message ?? "")).toContain("Third comment"); @@ -615,30 +616,14 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { await waitFor(() => gateway.getAgentPayloads().length === 2); const promotedPayload = gateway.getAgentPayloads()[1] ?? {}; - expect(promotedPayload.paperclip).toMatchObject({ - wake: { - commentIds: [queuedComment.id], - latestCommentId: queuedComment.id, - comments: [ - expect.objectContaining({ - id: queuedComment.id, - authorType: "user", - body: "Queued follow-up", - presentation: expect.objectContaining({ - kind: "system_notice", - tone: "warning", - }), - metadata: expect.objectContaining({ - version: 1, - }), - }), - ], - commentWindow: { - requestedCount: 1, - includedCount: 1, - missingCount: 0, - }, - }, + expect(promotedPayload.paperclip).toBeUndefined(); + const promotedWake = parseWakePayloadFromMessage(promotedPayload.message); + expect(promotedWake).toMatchObject({ + commentIds: [queuedComment.id], + latestCommentId: queuedComment.id, + requestedCount: 1, + includedCount: 1, + missingCount: 0, }); expect(String(promotedPayload.message ?? "")).toContain("Queued follow-up"); @@ -824,18 +809,18 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { }); const secondPayload = gateway.getAgentPayloads()[1] ?? {}; - expect(secondPayload.paperclip).toMatchObject({ - wake: { - reason: "issue_commented", - commentIds: [comment2.id], - latestCommentId: comment2.id, - issue: { - id: issueId, - identifier: `${issuePrefix}-1`, - title: "Reopen after deferred comment", - status: "in_progress", - priority: "medium", - }, + expect(secondPayload.paperclip).toBeUndefined(); + const secondWake = parseWakePayloadFromMessage(secondPayload.message); + expect(secondWake).toMatchObject({ + reason: "issue_commented", + commentIds: [comment2.id], + latestCommentId: comment2.id, + issue: { + id: issueId, + identifier: `${issuePrefix}-1`, + title: "Reopen after deferred comment", + status: "in_progress", + priority: "medium", }, }); expect(String(secondPayload.message ?? "")).toContain("Please handle this follow-up after you finish"); @@ -1024,18 +1009,18 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(issueAfterPromotion?.completedAt).not.toBeNull(); const secondPayload = gateway.getAgentPayloads()[1] ?? {}; - expect(secondPayload.paperclip).toMatchObject({ - wake: { - reason: "issue_comment_mentioned", - commentIds: [comment.id], - latestCommentId: comment.id, - issue: { - id: issueId, - identifier: `${issuePrefix}-1`, - title: "Do not reopen from agent mention", - status: "done", - priority: "medium", - }, + expect(secondPayload.paperclip).toBeUndefined(); + const secondWake = parseWakePayloadFromMessage(secondPayload.message); + expect(secondWake).toMatchObject({ + reason: "issue_comment_mentioned", + commentIds: [comment.id], + latestCommentId: comment.id, + issue: { + id: issueId, + identifier: `${issuePrefix}-1`, + title: "Do not reopen from agent mention", + status: "done", + priority: "medium", }, }); expect(String(secondPayload.message ?? "")).toContain("please review after I finish"); @@ -1401,18 +1386,18 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { }); const secondPayload = gateway.getAgentPayloads()[1] ?? {}; - expect(secondPayload.paperclip).toMatchObject({ - wake: { - reason: "issue_commented", - commentIds: [selfComment.id, humanComment.id], - latestCommentId: humanComment.id, - issue: { - id: issueId, - identifier: `${issuePrefix}-1`, - title: "Human follow-up must survive mixed deferred batches", - status: "in_progress", - priority: "medium", - }, + expect(secondPayload.paperclip).toBeUndefined(); + const secondWake = parseWakePayloadFromMessage(secondPayload.message); + expect(secondWake).toMatchObject({ + reason: "issue_commented", + commentIds: [selfComment.id, humanComment.id], + latestCommentId: humanComment.id, + issue: { + id: issueId, + identifier: `${issuePrefix}-1`, + title: "Human follow-up must survive mixed deferred batches", + status: "in_progress", + priority: "medium", }, }); expect(String(secondPayload.message ?? "")).toContain("Real follow-up from a human after the run closes"); @@ -1487,20 +1472,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { expect(firstRun).not.toBeNull(); await waitFor(() => gateway.getAgentPayloads().length === 1); const firstPayload = gateway.getAgentPayloads()[0] ?? {}; - expect(firstPayload.paperclip).toMatchObject({ - wake: { - reason: "issue_assigned", - issue: { - id: issueId, - identifier: `${issuePrefix}-1`, - title: "Require a comment", - status: "in_progress", - priority: "medium", - }, - checkedOutByHarness: true, - commentIds: [], - }, - }); + expect(firstPayload.paperclip).toBeUndefined(); expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload"); expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake."); expect(String(firstPayload.message ?? "")).toContain("- checkout: already claimed by the harness for this run"); @@ -1508,6 +1480,16 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => { "The harness already checked out this issue for the current run.", ); expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`); + const firstWake = parseWakePayloadFromMessage(firstPayload.message); + expect(firstWake).toMatchObject({ + reason: "issue_assigned", + checkedOutByHarness: true, + commentIds: [], + issue: { + id: issueId, + identifier: `${issuePrefix}-1`, + }, + }); const checkedOutIssue = await db .select({ status: issues.status, diff --git a/server/src/__tests__/helpers/wake-message.ts b/server/src/__tests__/helpers/wake-message.ts new file mode 100644 index 0000000000..14238e16b0 --- /dev/null +++ b/server/src/__tests__/helpers/wake-message.ts @@ -0,0 +1,13 @@ +// Wake context is embedded in the OpenClaw gateway `message` as a fenced ```json +// block — the gateway rejects unknown root params, so there is no top-level +// `paperclip` field on the agent payload. Parse the block back out so tests can +// assert against the structured payload instead of raw JSON substrings, keeping +// them robust to serialization formatting/key-order changes. +export function parseWakePayloadFromMessage(message: unknown): Record { + const text = String(message ?? ""); + const match = text.match(/```json\n([\s\S]*?)\n```/); + if (!match) { + throw new Error(`Expected a wake JSON block in gateway message, got: ${text}`); + } + return JSON.parse(match[1]) as Record; +} diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index fc52e4988c..ecd9b78682 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -32,6 +32,7 @@ import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; +import { parseWakePayloadFromMessage } from "./helpers/wake-message.js"; import { errorHandler } from "../middleware/index.js"; import { agentRoutes } from "../routes/agents.js"; import { issueRoutes } from "../routes/issues.js"; @@ -950,46 +951,40 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => expect(run).not.toBeNull(); await waitFor(() => gateway.getAgentPayloads().length === 1, 30_000); const payload = gateway.getAgentPayloads()[0] ?? {}; - expect(payload.paperclip).toMatchObject({ - wake: { - reason: "issue_commented", - issue: { - id: fixture.issues.reviewRoot.id, - title: fixture.issues.reviewRoot.title, - }, - latestCommentId: comment.body.id, - commentIds: [comment.body.id], - comments: [ - { - id: comment.body.id, - issueId: fixture.issues.assignedReview.id, - body: LOW_TRUST_QUARANTINED_BODY, - presentation: null, - metadata: null, - sourceTrust: { - preset: LOW_TRUST_REVIEW_PRESET, - disposition: "quarantined", - sourceIssueId: fixture.issues.assignedReview.id, - sourceRunId: fixture.runs.lowTrust.id, - sourceAgentId: fixture.agents.lowTrust.id, - }, - }, - ], - continuationSummary: { + // The gateway rejects unknown root params, so the wake context rides in the + // generated message rather than a top-level `paperclip` field. + expect(payload.paperclip).toBeUndefined(); + const wake = parseWakePayloadFromMessage(payload.message); + // Security-critical: low-trust quarantined output is redacted to the sanitized + // stub before it reaches the higher-trust wake/continuation context. The raw + // body must never appear (asserted by expectNoCanary below). The sourceTrust + // provenance is intentionally not carried in the agent-facing message form; its + // recording is covered by the route-response assertions earlier in this suite. + expect(wake).toMatchObject({ + reason: "issue_commented", + issue: { + id: fixture.issues.reviewRoot.id, + title: fixture.issues.reviewRoot.title, + }, + latestCommentId: comment.body.id, + commentIds: [comment.body.id], + comments: [ + { + id: comment.body.id, + issueId: fixture.issues.assignedReview.id, body: LOW_TRUST_QUARANTINED_BODY, - sourceTrust: { - preset: LOW_TRUST_REVIEW_PRESET, - disposition: "quarantined", - }, - }, - livenessContinuation: { - attempt: 1, - maxAttempts: 2, - sourceRunId: fixture.runs.lowTrust.id, - state: "quarantined_low_trust_handoff", - reason: "Low-trust review output requires sanitized follow-up.", - instruction: "Continue from the sanitized quarantine stub only.", }, + ], + continuationSummary: { + body: LOW_TRUST_QUARANTINED_BODY, + }, + livenessContinuation: { + attempt: 1, + maxAttempts: 2, + sourceRunId: fixture.runs.lowTrust.id, + state: "quarantined_low_trust_handoff", + reason: "Low-trust review output requires sanitized follow-up.", + instruction: "Continue from the sanitized quarantine stub only.", }, }); expect(String(payload.message ?? "")).toContain("## Paperclip Wake Payload"); diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 9bb85b7cdd..0d7a5e719c 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -502,12 +502,8 @@ describe("openclaw gateway adapter execute", () => { ); expect(String(payload?.message ?? "")).toContain("First comment"); expect(String(payload?.message ?? "")).toContain("\"commentIds\":[\"comment-1\",\"comment-2\"]"); - expect(payload?.paperclip).toMatchObject({ - wake: { - latestCommentId: "comment-2", - commentIds: ["comment-1", "comment-2"], - }, - }); + expect(payload?.paperclip).toBeUndefined(); + expect(String(payload?.message ?? "")).toContain("\"latestCommentId\":\"comment-2\""); expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); } finally {