From 2d5e6579316ddf06f126512f52d59401f862b689 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 10 Mar 2026 10:25:30 +0800 Subject: [PATCH 1/3] fix: 2026.3.8 regressions - config crash and cron deadlock Fixes two critical regressions in 2026.3.8: 1. Config Redaction RangeError (#41247, #40818) - Empty strings in sensitive fields caused RangeError - Filter out empty strings before redaction 2. Cron Job Deadlock (#41266, #41128, #41129) - Manual cron runs enqueued but never executed - Use subagent lane to avoid deadlock with cron lane Both fixes are minimal, targeted, and maintain backward compatibility. --- src/config/redact-snapshot.raw.ts | 5 ++++- src/gateway/server-cron.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/config/redact-snapshot.raw.ts b/src/config/redact-snapshot.raw.ts index 9f6f78a6724..ab063de8105 100644 --- a/src/config/redact-snapshot.raw.ts +++ b/src/config/redact-snapshot.raw.ts @@ -6,7 +6,10 @@ export function replaceSensitiveValuesInRaw(params: { sensitiveValues: string[]; redactedSentinel: string; }): string { - const values = [...params.sensitiveValues].toSorted((a, b) => b.length - a.length); + // FIX #41247: Filter out empty strings to prevent RangeError + const values = [...params.sensitiveValues] + .filter((v) => v && v.length > 0) + .toSorted((a, b) => b.length - a.length); let result = params.raw; for (const value of values) { result = result.replaceAll(value, params.redactedSentinel); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 1f1cd1f5359..6a76e6043d1 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -292,7 +292,10 @@ export function buildGatewayCronService(params: { abortSignal, agentId, sessionKey: `cron:${job.id}`, - lane: "cron", + // FIX #41266: Use subagent lane to avoid deadlock with cron lane + // The outer enqueueRun already holds CommandLane.Cron; using "cron" + // here would cause deadlock since cron lane has concurrency=1. + lane: "subagent", }); }, sendCronFailureAlert: async ({ job, text, channel, to, mode, accountId }) => { From 7e4e9d33713c67ff6e477d24085eba7eb7afff71 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 10 Mar 2026 10:38:02 +0800 Subject: [PATCH 2/3] fix(config): harden redaction against edge cases Add comprehensive defensive checks to prevent RangeError: - Filter null/undefined/non-string values - Early return for empty sensitive values array - Validate redaction sentinel - Add test coverage for edge cases Enhances fix for #41247, #40818 --- src/config/redact-snapshot.raw.test.ts | 87 ++++++++++++++++++++++++++ src/config/redact-snapshot.raw.ts | 32 +++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 src/config/redact-snapshot.raw.test.ts diff --git a/src/config/redact-snapshot.raw.test.ts b/src/config/redact-snapshot.raw.test.ts new file mode 100644 index 00000000000..b44375c86c6 --- /dev/null +++ b/src/config/redact-snapshot.raw.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { replaceSensitiveValuesInRaw } from "./redact-snapshot.raw.js"; + +describe("replaceSensitiveValuesInRaw", () => { + it("redacts sensitive values from raw string", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"apiKey": "secret123", "name": "test"}', + sensitiveValues: ["secret123"], + redactedSentinel: "__REDACTED__", + }); + expect(result).toBe('{"apiKey": "__REDACTED__", "name": "test"}'); + }); + + it("handles multiple sensitive values (longest first)", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"key1": "abc", "key2": "abcdef"}', + sensitiveValues: ["abc", "abcdef"], + redactedSentinel: "***", + }); + expect(result).toBe('{"key1": "***", "key2": "***"}'); + }); + + // Regression test for #41247 + it("handles empty strings without throwing RangeError", () => { + expect(() => + replaceSensitiveValuesInRaw({ + raw: '{"key": "value"}', + sensitiveValues: ["", "value"], + redactedSentinel: "__REDACTED__", + }), + ).not.toThrow(); + + const result = replaceSensitiveValuesInRaw({ + raw: '{"key": "value"}', + sensitiveValues: ["", "value"], + redactedSentinel: "__REDACTED__", + }); + expect(result).toBe('{"key": "__REDACTED__"}'); + }); + + it("handles null and undefined values", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"key": "secret"}', + sensitiveValues: [null, undefined, "secret"] as unknown as string[], + redactedSentinel: "***", + }); + expect(result).toBe('{"key": "***"}'); + }); + + it("returns raw unchanged when no valid sensitive values", () => { + const raw = '{"key": "value"}'; + const result = replaceSensitiveValuesInRaw({ + raw, + sensitiveValues: ["", null, undefined] as unknown as string[], + redactedSentinel: "__REDACTED__", + }); + expect(result).toBe(raw); + }); + + it("uses default sentinel when provided sentinel is empty", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"key": "secret"}', + sensitiveValues: ["secret"], + redactedSentinel: "", + }); + expect(result).toBe('{"key": "__REDACTED__"}'); + }); + + it("handles non-string raw input gracefully", () => { + const result = replaceSensitiveValuesInRaw({ + raw: null as unknown as string, + sensitiveValues: ["test"], + redactedSentinel: "***", + }); + // String(null) returns "null", but our defensive code returns empty string + expect(result).toBe(""); + }); + + it("handles unicode strings", () => { + const result = replaceSensitiveValuesInRaw({ + raw: '{"key": "🔑secret🔑"}', + sensitiveValues: ["🔑secret🔑"], + redactedSentinel: "***", + }); + expect(result).toBe('{"key": "***"}'); + }); +}); diff --git a/src/config/redact-snapshot.raw.ts b/src/config/redact-snapshot.raw.ts index ab063de8105..d2818134456 100644 --- a/src/config/redact-snapshot.raw.ts +++ b/src/config/redact-snapshot.raw.ts @@ -1,18 +1,44 @@ import { isDeepStrictEqual } from "node:util"; import JSON5 from "json5"; +/** + * Redacts sensitive values from a raw config string. + * Filters out empty/null/undefined values to prevent RangeError (#41247). + * + * Note: When `params.raw` is not a string (e.g., null, number), it is + * converted to a string via `String(params.raw ?? "")` and returned + * without redaction. This is a silent fallback for invalid input. + */ export function replaceSensitiveValuesInRaw(params: { raw: string; sensitiveValues: string[]; redactedSentinel: string; }): string { - // FIX #41247: Filter out empty strings to prevent RangeError + // Defensive: validate input types + if (typeof params.raw !== "string") { + return String(params.raw ?? ""); + } + + // Defensive: normalize and filter sensitive values + // Empty strings cause RangeError in String.replaceAll (#41247) const values = [...params.sensitiveValues] - .filter((v) => v && v.length > 0) + .filter((v): v is string => typeof v === "string" && v.length > 0) .toSorted((a, b) => b.length - a.length); + + // Early return if no valid values to redact + if (values.length === 0) { + return params.raw; + } + + // Defensive: ensure sentinel is valid + const sentinel = + typeof params.redactedSentinel === "string" && params.redactedSentinel.length > 0 + ? params.redactedSentinel + : "__REDACTED__"; + let result = params.raw; for (const value of values) { - result = result.replaceAll(value, params.redactedSentinel); + result = result.replaceAll(value, sentinel); } return result; } From 4c740f7dd3dc00a0eb19df2595954dff94d8927b Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 10 Mar 2026 20:37:41 +0800 Subject: [PATCH 3/3] fix: scope 2026.3.8 patch to redaction crash --- src/config/redact-snapshot.raw.test.ts | 12 +++++++++--- src/config/redact-snapshot.raw.ts | 7 +++---- src/gateway/server-cron.ts | 5 +---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/config/redact-snapshot.raw.test.ts b/src/config/redact-snapshot.raw.test.ts index b44375c86c6..17f3cdc09bf 100644 --- a/src/config/redact-snapshot.raw.test.ts +++ b/src/config/redact-snapshot.raw.test.ts @@ -67,13 +67,19 @@ describe("replaceSensitiveValuesInRaw", () => { }); it("handles non-string raw input gracefully", () => { - const result = replaceSensitiveValuesInRaw({ + const nullResult = replaceSensitiveValuesInRaw({ raw: null as unknown as string, sensitiveValues: ["test"], redactedSentinel: "***", }); - // String(null) returns "null", but our defensive code returns empty string - expect(result).toBe(""); + const objectResult = replaceSensitiveValuesInRaw({ + raw: { secret: "test" } as unknown as string, + sensitiveValues: ["test"], + redactedSentinel: "***", + }); + + expect(nullResult).toBe(""); + expect(objectResult).toBe(""); }); it("handles unicode strings", () => { diff --git a/src/config/redact-snapshot.raw.ts b/src/config/redact-snapshot.raw.ts index d2818134456..06c0b2ef0bb 100644 --- a/src/config/redact-snapshot.raw.ts +++ b/src/config/redact-snapshot.raw.ts @@ -5,9 +5,8 @@ import JSON5 from "json5"; * Redacts sensitive values from a raw config string. * Filters out empty/null/undefined values to prevent RangeError (#41247). * - * Note: When `params.raw` is not a string (e.g., null, number), it is - * converted to a string via `String(params.raw ?? "")` and returned - * without redaction. This is a silent fallback for invalid input. + * Note: When `params.raw` is not a string, this returns an empty string + * defensively instead of returning a stringified unredacted value. */ export function replaceSensitiveValuesInRaw(params: { raw: string; @@ -16,7 +15,7 @@ export function replaceSensitiveValuesInRaw(params: { }): string { // Defensive: validate input types if (typeof params.raw !== "string") { - return String(params.raw ?? ""); + return ""; } // Defensive: normalize and filter sensitive values diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 6a76e6043d1..1f1cd1f5359 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -292,10 +292,7 @@ export function buildGatewayCronService(params: { abortSignal, agentId, sessionKey: `cron:${job.id}`, - // FIX #41266: Use subagent lane to avoid deadlock with cron lane - // The outer enqueueRun already holds CommandLane.Cron; using "cron" - // here would cause deadlock since cron lane has concurrency=1. - lane: "subagent", + lane: "cron", }); }, sendCronFailureAlert: async ({ job, text, channel, to, mode, accountId }) => {