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:
parent
b3209486ca
commit
4b1332b61c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue