fix(openclaw-gateway): drop root paperclip params (#4416)

Fixes #5997, fixes #4081, fixes #4723, fixes #6625, fixes #3923

Refs #6606 — this PR removes the rejected root `paperclip` field, but
#6606 also requires the protocol v3→v4 bump, which is out of scope here;
referencing rather than closing it.

## Thinking Path

> - Paperclip is the control plane that wakes and coordinates agent
workers across company-scoped execution flows.
> - The `openclaw_gateway` adapter is part of that wake path, so its
outbound payload contract has to match the gateway's validated `agent`
schema.
> - `master` currently reintroduces a previously fixed regression by
sending a top-level `paperclip` property in `agentParams` (see #3923,
which reverts the original fix in #626).
> - The gateway rejects unknown root params, which means OpenClaw wakes
fail before the remote agent can start work.
> - The actual wake context already rides in the generated `message`, so
the extra root property is both redundant and harmful.
> - This pull request removes that leaked root property, adds a focused
regression test around param construction, and updates affected server
expectations/docs to the supported contract.
> - The benefit is that OpenClaw Gateway agents wake successfully again
without losing inline wake context.

## What Changed

- Removed the top-level `paperclip` field from OpenClaw Gateway
`agentParams` and extracted `buildAgentParams()` so the contract is easy
to test.
- Added a package-level regression test that proves
`payloadTemplate.paperclip` is stripped while explicit
`agentId`/`timeout` behavior stays intact.
- Updated server tests that inspect OpenClaw Gateway payloads to assert
wake data is delivered in `message` instead of a rejected root field.
- Updated the adapter configuration docs to state that wake context is
embedded in the generated message text, not sent as a top-level param.

### Rebase onto current `master` (conflict resolution)

This branch was opened against an older `master`; re-merging current
`master` required:

- Resolving conflicts in `execute.ts` — `master` hoisted
`configuredAgentId` and moved the agentId/timeout precedence inline;
this PR keeps the `buildAgentParams()` extraction that strips the
gateway-rejected root `paperclip`.
- Updating tests `master` added **after** this branch's base that assert
the old root-`paperclip` contract. These suites use the OpenClaw gateway
adapter purely as a delivery harness (`adapterType: "openclaw_gateway"`
+ a mock gateway) and observe wake content via the gateway payload, so
dropping the root field requires them to read wake context from
`message` instead:
  - `server/src/__tests__/heartbeat-comment-wake-batching.test.ts`
- `server/src/__tests__/low-trust-red-team-routes.test.ts` (redaction
guarantees preserved — sanitized body + `expectNoCanary` on the raw
canary)
- Replaced brittle JSON-substring assertions (flagged by Greptile) with
a shared `parseWakePayloadFromMessage()` helper + `toMatchObject`,
robust to serialization/key-order changes.

The strict contract is confirmed upstream: OpenClaw's
`AgentParamsSchema` is `Type.Object(..., { additionalProperties: false
})` with no `paperclip` field, so a root `paperclip` is rejected
(`invalid agent params: at root: unexpected property 'paperclip'`).

## Verification

- `pnpm --filter @paperclipai/adapter-openclaw-gateway typecheck` —
clean
- `pnpm --filter @paperclipai/server typecheck` — clean
- `pnpm exec vitest run --project @paperclipai/server
server/src/__tests__/openclaw-gateway-adapter.test.ts
server/src/__tests__/heartbeat-comment-wake-batching.test.ts
server/src/__tests__/low-trust-red-team-routes.test.ts` — 26 passed (7 +
11 + 8)
- `packages/adapters/openclaw-gateway/src/server/execute.test.ts` — 6
passed (run via a local temp vitest config because the root
`vitest.config.ts` does not include this package)

## Risks

- Low risk: this narrows the outbound payload to the gateway-supported
contract and keeps wake context in the already-supported `message`
channel.
- Any downstream consumer that incorrectly depended on a top-level
`paperclip` field from the gateway mock payloads would need to follow
the supported `message` contract instead.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex CLI coding agent via API authored the original change;
exact underlying model ID and context window were not exposed in that
environment.
- Rebase/conflict resolution and the test-assertion migration were done
with Claude Code (Claude Opus 4.8, 1M context).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked the
related issues above
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: serenakeyitan via breeze-runner <serenakeyitan@users.noreply.github.com>
Co-authored-by: Andrew Aymeloglu <aaymeloglu@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serena 2026-06-23 16:32:15 -07:00 committed by GitHub
parent b3209486ca
commit 4b1332b61c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 188 additions and 193 deletions

View File

@ -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

View File

@ -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",
});
});
});

View File

@ -470,60 +470,32 @@ function joinWakePayloadSections(structuredWakePrompt: string, structuredWakeJso
return sections.join("\n");
}
function buildStandardPaperclipPayload(
ctx: AdapterExecutionContext,
wakePayload: WakePayload,
paperclipEnv: Record<string, string>,
payloadTemplate: Record<string, unknown>,
): Record<string, unknown> {
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<string, unknown> => Boolean(asRecord(entry)))
: [];
const configuredWorkspaceRuntime = parseObject(ctx.config.workspaceRuntime);
const runtimeServiceIntents = Array.isArray(ctx.context.paperclipRuntimeServiceIntents)
? ctx.context.paperclipRuntimeServiceIntents.filter(
(entry): entry is Record<string, unknown> => Boolean(asRecord(entry)),
)
: [];
const standardPaperclip: Record<string, unknown> = {
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<string, unknown>;
message: string;
sessionKey: string;
runId: string;
configuredAgentId: string | null;
waitTimeoutMs: number;
}): Record<string, unknown> {
const agentParams: Record<string, unknown> = {
...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<AdapterExec
const templateMessage = nonEmpty(payloadTemplate.message) ?? nonEmpty(payloadTemplate.text);
const message = templateMessage ? appendWakeText(templateMessage, wakeText) : wakeText;
const paperclipPayload = buildStandardPaperclipPayload(ctx, wakePayload, paperclipEnv, payloadTemplate);
const agentParams: Record<string, unknown> = {
...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({

View File

@ -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,

View File

@ -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<string, unknown> {
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<string, unknown>;
}

View File

@ -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");

View File

@ -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 {