Skip to content

Commit 410e411

Browse files
authored
fix(system-prompt): tighten cache boundary proof
1 parent 8358284 commit 410e411

40 files changed

Lines changed: 1028 additions & 105 deletions

docs/automation/cron-jobs.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ If stdout is non-empty, that text is the delivered result. If stdout is empty an
169169
<ParamField path="--thinking" type="string">
170170
Thinking level override.
171171
</ParamField>
172+
<ParamField path="--clear-thinking" type="boolean">
173+
On `cron edit`, removes the per-job thinking override so the job follows normal cron thinking precedence. Cannot be combined with `--thinking`.
174+
</ParamField>
172175
<ParamField path="--light-context" type="boolean">
173176
Skip workspace bootstrap file injection.
174177
</ParamField>

docs/cli/cron.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ Use `--due` when you want the manual command to run only if the job is currently
170170

171171
## Models
172172

173-
`cron add|edit --model <ref>` selects an allowed model for the job. `cron add|edit --fallbacks <list>` sets per-job fallback models, for example `--fallbacks openrouter/gpt-4.1-mini,openai/gpt-5`; pass `--fallbacks ""` for a strict run with no fallbacks. `cron edit <job-id> --clear-fallbacks` removes the per-job fallback override. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`.
173+
`cron add|edit --model <ref>` selects an allowed model for the job. `cron add|edit --fallbacks <list>` sets per-job fallback models, for example `--fallbacks openrouter/gpt-4.1-mini,openai/gpt-5`; pass `--fallbacks ""` for a strict run with no fallbacks. `cron edit <job-id> --clear-fallbacks` removes the per-job fallback override. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`. `cron add|edit --thinking <level>` sets a per-job thinking override; `cron edit <job-id> --clear-thinking` removes it so the job follows normal cron thinking precedence, and it cannot be combined with `--thinking`.
174174

175175
<Warning>
176176
If the model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of falling back to the job's agent or default model selection.

extensions/discord/src/api.test.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
// Discord tests cover api plugin behavior.
2+
import { createServer, type Server } from "node:http";
23
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
34
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
4-
import { beforeEach, describe, expect, it, vi } from "vitest";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
56
import { DiscordApiError, fetchDiscord, requestDiscord } from "./api.js";
67
import { jsonResponse } from "./test-http-helpers.js";
78

9+
const DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES = 4 * 1024 * 1024;
10+
811
function cancelTrackedResponse(
912
text: string,
1013
init: ResponseInit,
@@ -27,11 +30,56 @@ function cancelTrackedResponse(
2730
};
2831
}
2932

33+
async function listenLoopbackServer(server: Server): Promise<number> {
34+
return await new Promise((resolve, reject) => {
35+
server.once("error", reject);
36+
server.listen(0, "127.0.0.1", () => {
37+
server.off("error", reject);
38+
const address = server.address();
39+
if (!address || typeof address === "string") {
40+
reject(new Error("expected loopback TCP address"));
41+
return;
42+
}
43+
resolve(address.port);
44+
});
45+
});
46+
}
47+
48+
async function closeServer(server: Server): Promise<void> {
49+
await new Promise<void>((resolve) => {
50+
server.close(() => resolve());
51+
});
52+
}
53+
54+
function stubDiscordFetchToLoopback(
55+
baseUrl: string,
56+
onResponse?: (response: Response) => void,
57+
): void {
58+
const realFetch = globalThis.fetch.bind(globalThis);
59+
vi.stubGlobal(
60+
"fetch",
61+
withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
62+
const originalUrl = new URL(input instanceof Request ? input.url : String(input));
63+
expect(originalUrl.origin).toBe("https://discord.com");
64+
expect(originalUrl.pathname).toMatch(/^\/api\/v10\//);
65+
const loopbackUrl = new URL(`${originalUrl.pathname}${originalUrl.search}`, baseUrl);
66+
const response = await realFetch(loopbackUrl, init);
67+
onResponse?.(response);
68+
return response;
69+
}),
70+
);
71+
}
72+
3073
describe("fetchDiscord", () => {
3174
beforeEach(() => {
3275
vi.useRealTimers();
3376
});
3477

78+
afterEach(() => {
79+
vi.restoreAllMocks();
80+
vi.unstubAllGlobals();
81+
});
82+
3583
it("formats rate limit payloads without raw JSON", async () => {
3684
const fetcher = withFetchPreconnect(async () =>
3785
jsonResponse(
@@ -272,4 +320,96 @@ describe("fetchDiscord", () => {
272320
expect(timeoutSpy).toHaveBeenCalledWith(MAX_TIMER_TIMEOUT_MS);
273321
expect(request?.signal).toBe(timeoutController.signal);
274322
});
323+
324+
it("returns under-cap requestDiscord responses from a real loopback HTTP server", async () => {
325+
const payload = { id: "channel-42", name: "loopback", type: 0 };
326+
let contentLength: string | null | undefined;
327+
let requestUrl: string | undefined;
328+
let authorization: string | undefined;
329+
const server = createServer((req, res) => {
330+
requestUrl = req.url;
331+
authorization = req.headers.authorization;
332+
const body = JSON.stringify(payload);
333+
res.writeHead(200, { "content-type": "application/json" });
334+
res.write(body.slice(0, 12));
335+
res.end(body.slice(12));
336+
});
337+
const port = await listenLoopbackServer(server);
338+
339+
try {
340+
stubDiscordFetchToLoopback(`http://127.0.0.1:${port}`, (response) => {
341+
contentLength = response.headers.get("content-length");
342+
});
343+
344+
const result = await requestDiscord<typeof payload>("/channels/channel-42", "test-token", {
345+
retry: { attempts: 1 },
346+
});
347+
348+
expect(result).toEqual(payload);
349+
expect(requestUrl).toBe("/api/v10/channels/channel-42");
350+
expect(authorization).toBe("Bot test-token");
351+
expect(contentLength).toBeNull();
352+
console.log(
353+
`[discord requestDiscord loopback proof] normal path: returned=${JSON.stringify(result)} content_length=${contentLength ?? "none"}`,
354+
);
355+
} finally {
356+
await closeServer(server);
357+
}
358+
});
359+
360+
it("rejects oversized valid JSON requestDiscord responses from a real loopback HTTP server", async () => {
361+
const oversizedPayloadBytes = DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES + 256 * 1024;
362+
let contentLength: string | null | undefined;
363+
let requestUrl: string | undefined;
364+
let streamedBytes = 0;
365+
const server = createServer((req, res) => {
366+
requestUrl = req.url;
367+
const chunk = Buffer.alloc(64 * 1024, 0x78);
368+
res.writeHead(200, { "content-type": "application/json" });
369+
res.write('{"id":"');
370+
371+
const writeMore = () => {
372+
while (streamedBytes < oversizedPayloadBytes) {
373+
if (res.destroyed) {
374+
return;
375+
}
376+
streamedBytes += chunk.byteLength;
377+
if (!res.write(chunk)) {
378+
res.once("drain", writeMore);
379+
return;
380+
}
381+
}
382+
res.end('"}');
383+
};
384+
385+
writeMore();
386+
});
387+
const port = await listenLoopbackServer(server);
388+
389+
try {
390+
stubDiscordFetchToLoopback(`http://127.0.0.1:${port}`, (response) => {
391+
contentLength = response.headers.get("content-length");
392+
});
393+
394+
let error: unknown;
395+
try {
396+
await requestDiscord("/channels/123/messages", "test-token", {
397+
retry: { attempts: 1 },
398+
});
399+
} catch (err) {
400+
error = err;
401+
}
402+
403+
expect(error).toBeInstanceOf(Error);
404+
expect(String(error)).toContain("Discord API /channels/123/messages response body too large");
405+
expect(String(error)).toContain(`limit: ${DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES} bytes`);
406+
expect(requestUrl).toBe("/api/v10/channels/123/messages");
407+
expect(contentLength).toBeNull();
408+
console.log(
409+
`[discord requestDiscord loopback proof] oversized path: cap=${DISCORD_SUCCESS_RESPONSE_LIMIT_BYTES} streamed>=${streamedBytes} content_length=${contentLength ?? "none"} rejected=${String(error)}`,
410+
);
411+
} finally {
412+
await closeServer(server);
413+
}
414+
});
275415
});

extensions/discord/src/api.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
33
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
44
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
5+
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
56
import {
67
resolveRetryConfig,
78
retryAsync,
@@ -19,6 +20,7 @@ const DISCORD_API_RETRY_DEFAULTS = {
1920
};
2021
const DISCORD_API_429_FALLBACK_RETRY_AFTER_SECONDS = 60;
2122
const DISCORD_API_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
23+
const DISCORD_API_RESPONSE_BODY_LIMIT_BYTES = 4 * 1024 * 1024;
2224

2325
type DiscordApiErrorPayload = {
2426
message?: string;
@@ -191,7 +193,13 @@ export async function requestDiscord<T>(
191193
retryAfter,
192194
);
193195
}
194-
const text = await res.text().catch(() => "");
196+
const responseBody = await readResponseWithLimit(res, DISCORD_API_RESPONSE_BODY_LIMIT_BYTES, {
197+
onOverflow: ({ size, maxBytes }) =>
198+
new Error(
199+
`Discord API ${path} response body too large: ${size} bytes (limit: ${maxBytes} bytes)`,
200+
),
201+
});
202+
const text = new TextDecoder().decode(responseBody);
195203
if (!text.trim()) {
196204
return undefined as T;
197205
}

extensions/irc/src/protocol.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,52 @@ describe("irc protocol", () => {
3636
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
3737
});
3838

39+
describe("\\u escape surrogate-range guard", () => {
40+
const LONE_SURROGATE = /[\uD800-\uDFFF]/;
41+
42+
it("preserves literal \\uXXXX when codepoint is a high surrogate", () => {
43+
const out = sanitizeIrcOutboundText("\\uD800");
44+
expect(LONE_SURROGATE.test(out)).toBe(false);
45+
});
46+
47+
it("preserves literal \\uXXXX when codepoint is a low surrogate", () => {
48+
const out = sanitizeIrcOutboundText("\\uDFFF");
49+
expect(LONE_SURROGATE.test(out)).toBe(false);
50+
});
51+
52+
it("still decodes valid BMP codepoints outside the surrogate range", () => {
53+
expect(sanitizeIrcOutboundText("\\u0041")).toBe("A");
54+
expect(sanitizeIrcOutboundText("\\u00e9")).toBe("é"); // é
55+
});
56+
57+
it("decodes adjacent surrogate-pair escapes to the astral character", () => {
58+
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00")).toBe("😀");
59+
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00\\uD83D\\uDE01")).toBe("😀😁");
60+
});
61+
62+
it("preserves lone high surrogate even when followed by a non-surrogate \\u", () => {
63+
const out = sanitizeIrcOutboundText("\\uD800\\u0041");
64+
expect(LONE_SURROGATE.test(out)).toBe(false);
65+
expect(out).toContain("A");
66+
});
67+
68+
it("decodes BMP-escaped prefix before a surrogate pair correctly", () => {
69+
// Regression: \\u0041\\uD83D\\uDE00 must yield A😀, not A\\uD83D\\uDE00.
70+
// The old step-1 regex \\u(xxxx)\\u(xxxx) would consume \\u0041\\uD83D as a
71+
// non-pair, leaving \\uDE00 as a lone surrogate.
72+
expect(sanitizeIrcOutboundText("\\u0041\\uD83D\\uDE00")).toBe("A😀");
73+
});
74+
75+
it("handles lone high surrogate followed by a different surrogate pair", () => {
76+
// \\uD800\\uD83D\\uDE00: D800 is lone (no matching low), D83D+DE00 form 😀.
77+
// Use toBe rather than LONE_SURROGATE regex: emoji contains surrogate
78+
// code units internally that would trigger a naive /[\uD800-\uDFFF]/ check.
79+
expect(sanitizeIrcOutboundText("\\uD800\\uD83D\\uDE00")).toBe("\\uD800😀");
80+
});
81+
82+
it("preserves two consecutive lone high surrogates", () => {
83+
const out = sanitizeIrcOutboundText("\\uD800\\uD801");
84+
expect(LONE_SURROGATE.test(out)).toBe(false);
85+
});
86+
});
3987
});

extensions/irc/src/protocol.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,28 @@ export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
109109
function decodeLiteralEscapes(input: string): string {
110110
// Defensive: this is not a full JS string unescaper.
111111
// It's just enough to catch common "\r\n" / "\u0001" style payloads.
112-
return input
113-
.replace(/\\r/g, "\r")
114-
.replace(/\\n/g, "\n")
115-
.replace(/\\t/g, "\t")
116-
.replace(/\\0/g, "\0")
117-
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
118-
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
112+
return (
113+
input
114+
.replace(/\\r/g, "\r")
115+
.replace(/\\n/g, "\n")
116+
.replace(/\\t/g, "\t")
117+
.replace(/\\0/g, "\0")
118+
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
119+
// Step 1: decode surrogate pairs — first group locked to high surrogate range
120+
// (U+D800–U+DBFF: [dD][89abAB]xx), second to low surrogate range
121+
// (U+DC00–U+DFFF: [dD][c-fC-F]xx). This prevents a non-surrogate \uXXXX
122+
// from being greedily paired with the following high surrogate and consuming it.
123+
.replace(/\\u([dD][89abAB][0-9a-fA-F]{2})\\u([dD][c-fC-F][0-9a-fA-F]{2})/g, (_match, h, l) =>
124+
String.fromCodePoint(
125+
0x10000 + ((Number.parseInt(h, 16) - 0xd800) << 10) + (Number.parseInt(l, 16) - 0xdc00),
126+
),
127+
)
128+
// Step 2: decode BMP codepoints; preserve lone surrogates as literals
129+
.replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => {
130+
const codePoint = Number.parseInt(hex, 16);
131+
return codePoint >= 0xd800 && codePoint <= 0xdfff ? match : String.fromCharCode(codePoint);
132+
})
133+
);
119134
}
120135

121136
export function sanitizeIrcOutboundText(text: string): string {

extensions/memory-core/src/memory/index.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2290,4 +2290,26 @@ describe("memory index", () => {
22902290
restoreMemoryIndexStateDir();
22912291
}
22922292
});
2293+
it("status-purpose manager detects unindexed session transcripts as dirty", async () => {
2294+
// Regression test for #97814: plain openclaw memory status (purpose: status)
2295+
// must report dirty=true when session files exist without index rows.
2296+
const cfg = createCfg({ sources: ["sessions"], sessionMemory: true });
2297+
const stateDirName = ".state-status-dirty-test";
2298+
setMemoryIndexStateDir(path.join(workspaceDir, stateDirName));
2299+
try {
2300+
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
2301+
await fs.mkdir(sessionsDir, { recursive: true });
2302+
const transcriptPath = path.join(sessionsDir, "status-dirty-test.jsonl");
2303+
await fs.writeFile(transcriptPath, JSON.stringify({ type: "test", ts: 1 }) + "\n");
2304+
2305+
const manager = await getFreshManager(cfg, "status");
2306+
managersForCleanup.add(manager);
2307+
2308+
const result = manager.status();
2309+
expect(result.dirty).toBe(true);
2310+
} finally {
2311+
restoreMemoryIndexStateDir();
2312+
}
2313+
});
2314+
22932315
});

extensions/memory-core/src/memory/manager.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,16 +355,29 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
355355
pending: INDEX_CACHE_PENDING,
356356
key,
357357
bypassCache: transient,
358-
create: async () =>
359-
new MemoryIndexManager({
358+
create: async () => {
359+
const manager = new MemoryIndexManager({
360360
cacheKey: key,
361361
cfg,
362362
agentId,
363363
workspaceDir,
364364
settings,
365365
providerRequirement,
366366
purpose: params.purpose,
367-
}),
367+
});
368+
// Lightweight dirty-file detection for status mode: check for unindexed
369+
// session files on disk without triggering a full sync. This runs before
370+
// any caller reads manager.status(), so the dirty flag is accurate when
371+
// status() reads sessionsDirty.
372+
if (purpose === "status" && manager.sources.has("sessions")) {
373+
try {
374+
await manager.markSessionStartupCatchupDirtyFiles();
375+
} catch (err) {
376+
log.warn("memory status session dirty detection failed: " + String(err));
377+
}
378+
}
379+
return manager;
380+
},
368381
});
369382
}
370383

0 commit comments

Comments
 (0)