Skip to content

Commit afa0f8f

Browse files
committed
fix(thread-ownership): bound forwarder conflict handling
1 parent e5265f9 commit afa0f8f

3 files changed

Lines changed: 43 additions & 18 deletions

File tree

extensions/thread-ownership/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Thread Ownership API module exposes the plugin public contract.
22
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
33
export { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
4+
export { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
45
export {
56
fetchWithSsrFGuard,
67
ssrfPolicyFromDangerouslyAllowPrivateNetwork,

extensions/thread-ownership/index.test.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,20 +272,39 @@ describe("thread-ownership plugin", () => {
272272
expect(infoMessage).toContain("cancelled send");
273273
});
274274

275-
it("cancels when the 409 conflict body is truncated or malformed", async () => {
276-
// Simulates an over-cap or malformed 409 body: readResponseTextLimited
277-
// stops at the cap, JSON.parse fails on the truncated text, but the 409
278-
// status itself means another agent owns this thread — still cancel.
275+
it("cancels when the forwarder conflict JSON is malformed", async () => {
276+
vi.mocked(globalThis.fetch).mockResolvedValue(new Response("{", { status: 409 }));
277+
278+
const result = await sendSlackThreadMessage();
279+
280+
expect(result).toEqual({ cancel: true });
281+
const warningMessage = requireFirstLogMessage(
282+
api.logger.warn,
283+
"ownership conflict warning log",
284+
);
285+
expect(warningMessage).toContain("conflict body unreadable");
286+
expect(warningMessage).toContain("malformed JSON response");
287+
const infoMessage = requireFirstLogMessage(api.logger.info, "ownership cancel info log");
288+
expect(infoMessage).toContain("cancelled send");
289+
expect(infoMessage).toContain("owned by unknown");
290+
});
291+
292+
it("cancels when the forwarder conflict JSON exceeds the bounded read limit", async () => {
279293
vi.mocked(globalThis.fetch).mockResolvedValue(
280-
new Response('{ "owner": "other-agent" ,', { status: 409 }),
294+
new Response(JSON.stringify({ owner: "x".repeat(70 * 1024) }), { status: 409 }),
281295
);
282296

283297
const result = await sendSlackThreadMessage();
284298

285299
expect(result).toEqual({ cancel: true });
300+
const warningMessage = requireFirstLogMessage(
301+
api.logger.warn,
302+
"ownership conflict warning log",
303+
);
304+
expect(warningMessage).toContain("conflict body unreadable");
305+
expect(warningMessage).toContain("JSON response exceeds 65536 bytes");
286306
const infoMessage = requireFirstLogMessage(api.logger.info, "ownership cancel info log");
287307
expect(infoMessage).toContain("cancelled send");
288-
// The owner field was unparseable, so the log falls back to "unknown".
289308
expect(infoMessage).toContain("owned by unknown");
290309
});
291310

extensions/thread-ownership/index.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
// Thread Ownership plugin entrypoint registers its OpenClaw integration.
22
import { resolveLivePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
3-
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
43
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
54
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
65
import {
76
definePluginEntry,
87
fetchWithSsrFGuard,
8+
readProviderJsonResponse,
99
ssrfPolicyFromDangerouslyAllowPrivateNetwork,
1010
type OpenClawConfig,
1111
type OpenClawPluginApi,
1212
} from "./api.js";
1313

14-
/** Bound a forwarder 409 body; a compromised forwarder must not OOM the agent. */
15-
const THREAD_OWNERSHIP_CONFLICT_BODY_LIMIT_BYTES = 8 * 1024;
14+
const THREAD_OWNERSHIP_CONFLICT_BODY_LIMIT_BYTES = 64 * 1024;
1615

1716
type ThreadOwnershipConfig = {
1817
forwarderUrl?: string;
@@ -193,18 +192,24 @@ export default definePluginEntry({
193192
return undefined;
194193
}
195194
if (resp.status === 409) {
196-
let owner: string | undefined;
195+
let owner = "unknown";
197196
try {
198-
const body = JSON.parse(
199-
await readResponseTextLimited(resp, THREAD_OWNERSHIP_CONFLICT_BODY_LIMIT_BYTES),
200-
) as { owner?: string };
201-
owner = body.owner;
202-
} catch {
203-
// Truncated or malformed 409 body; the 409 status itself means
204-
// another agent owns this thread — still cancel.
197+
const body = await readProviderJsonResponse<{ owner?: unknown }>(
198+
resp,
199+
"thread-ownership forwarder conflict",
200+
{ maxBytes: THREAD_OWNERSHIP_CONFLICT_BODY_LIMIT_BYTES },
201+
);
202+
if (typeof body.owner === "string" && body.owner) {
203+
owner = body.owner;
204+
}
205+
} catch (error) {
206+
// A 409 is authoritative even when its body is malformed or oversized.
207+
api.logger.warn?.(
208+
`thread-ownership: conflict body unreadable (${String(error)}), cancelling send`,
209+
);
205210
}
206211
api.logger.info?.(
207-
`thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${owner ?? "unknown"}`,
212+
`thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${owner}`,
208213
);
209214
return { cancel: true };
210215
}

0 commit comments

Comments
 (0)