From d5bf37d8ed143eb6baae949c224f461b5379a208 Mon Sep 17 00:00:00 2001 From: Paperclip Deployment Engineer Date: Fri, 7 Aug 2026 22:06:33 +0000 Subject: [PATCH] fix: harden 402 guard recursion depth and make SDK API drift noisy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bound findStringField recursion to maxDepth=10 so adversarially deep 402 bodies cannot cause runaway recursion. Real 402 bodies nest 3-4 levels; 10 is generous while bounding the worst case. - Add runtime shape checks with logger.warn() to all four monkey-patches (_handleErrorResponse, createProviderToken, topUp) so SDK API drift is noisy instead of silent. The topUp patch previously called .bind() without verifying the method exists — a real TypeError on SDK upgrade. - Thread logger into installProvider402BodyCapture for the new warning. - Tests: depth-limit edge cases (4-level match + 50-level no-crash) and three API-drift detection tests verifying warnings fire when SDK methods are missing. --- src/daemon/wallet/provider-402-guard.ts | 19 ++++++-- src/daemon/wallet/sdk-mint-fallback.ts | 38 +++++++++++++-- tests/provider-402-guard.test.ts | 16 +++++++ tests/sdk-mint-fallback-hardening.test.ts | 56 +++++++++++++++++++++++ 4 files changed, 121 insertions(+), 8 deletions(-) diff --git a/src/daemon/wallet/provider-402-guard.ts b/src/daemon/wallet/provider-402-guard.ts index 5dba650..e0189d1 100644 --- a/src/daemon/wallet/provider-402-guard.ts +++ b/src/daemon/wallet/provider-402-guard.ts @@ -86,9 +86,20 @@ function safeStringify(value: unknown): string { } } -/** Recursively find the first string-valued occurrence of `field`. */ -function findStringField(value: unknown, field: string): string | undefined { - if (!value || typeof value !== "object") return undefined; +/** + * Recursively find the first string-valued occurrence of `field`. + * + * Bounded to `maxDepth` levels so an adversarially deep or cyclic-ish 402 + * body cannot cause runaway recursion. Real 402 bodies nest 3-4 levels deep + * at most (`{"detail":{"error":{"message":...,"code":...}}}`), so 10 is + * generous while still bounding the worst case. + */ +function findStringField( + value: unknown, + field: string, + maxDepth = 10, +): string | undefined { + if (!value || typeof value !== "object" || maxDepth <= 0) return undefined; for (const [key, entry] of Object.entries(value as Record)) { if (key === field && typeof entry === "string" && entry.trim()) { @@ -96,7 +107,7 @@ function findStringField(value: unknown, field: string): string | undefined { } } for (const entry of Object.values(value as Record)) { - const nested = findStringField(entry, field); + const nested = findStringField(entry, field, maxDepth - 1); if (nested) return nested; } return undefined; diff --git a/src/daemon/wallet/sdk-mint-fallback.ts b/src/daemon/wallet/sdk-mint-fallback.ts index dc96fd2..69fd492 100644 --- a/src/daemon/wallet/sdk-mint-fallback.ts +++ b/src/daemon/wallet/sdk-mint-fallback.ts @@ -72,12 +72,19 @@ const ERROR_BODY_PATCH_MARKER = Symbol.for("routstrd.provider402BodyPatched"); export function installProvider402BodyCapture( client: RoutstrClientLike, guard: Provider402Guard, + logger?: LoggerLike, ): void { const target = client as RoutstrClientLike & { [ERROR_BODY_PATCH_MARKER]?: boolean; _handleErrorResponse?: (...args: unknown[]) => Promise; }; - if (target[ERROR_BODY_PATCH_MARKER] || typeof target._handleErrorResponse !== "function") { + if (target[ERROR_BODY_PATCH_MARKER]) return; + if (typeof target._handleErrorResponse !== "function") { + logger?.warn( + "[wallet] _handleErrorResponse not found on SDK client — " + + "402 body capture disabled. If the SDK has changed its API, " + + "this patch and the 402 guard need updating.", + ); return; } @@ -294,7 +301,15 @@ export function installCreateProviderTokenFallback( createProviderToken?: (options: TopUpOptions) => Promise; [CREATE_TOKEN_PATCH_MARKER]?: boolean; }; - if (balanceManager[CREATE_TOKEN_PATCH_MARKER] || typeof balanceManager.createProviderToken !== "function") return; + if (balanceManager[CREATE_TOKEN_PATCH_MARKER]) return; + if (typeof balanceManager.createProviderToken !== "function") { + logger.warn( + "[wallet] createProviderToken not found on balance manager — " + + "createProviderToken fallback disabled. If the SDK has changed " + + "its API, this patch needs updating.", + ); + return; + } const originalCreateProviderToken = balanceManager.createProviderToken.bind(balanceManager); @@ -477,12 +492,20 @@ export function installMintFallbackTopUp( installCreateProviderTokenFallback(client, walletClient, walletAdapter, logger, upstreamProviderUrl); const guard = provider402Guard ?? createProvider402Guard(); - installProvider402BodyCapture(client, guard); + installProvider402BodyCapture(client, guard, logger); const balanceManager = client.getBalanceManager() as BalanceManagerLike & { [PATCH_MARKER]?: boolean; }; if (balanceManager[PATCH_MARKER]) return; + if (typeof balanceManager.topUp !== "function") { + logger.warn( + "[wallet] topUp not found on balance manager — " + + "mint fallback top-up disabled. If the SDK has changed " + + "its API, this patch needs updating.", + ); + return; + } const originalTopUp = balanceManager.topUp.bind(balanceManager); balanceManager.topUp = async (options: TopUpOptions): Promise => { @@ -664,7 +687,14 @@ export function installMintUnreachableErrorRetry( if (patchedClient[ERROR_RETRY_PATCH_MARKER]) return; const originalHandleErrorResponse = patchedClient._handleErrorResponse; - if (typeof originalHandleErrorResponse !== "function") return; + if (typeof originalHandleErrorResponse !== "function") { + logger.warn( + "[wallet] _handleErrorResponse not found on SDK client — " + + "mint-unreachable retry disabled. If the SDK has changed " + + "its API, this patch needs updating.", + ); + return; + } patchedClient._handleErrorResponse = async function patchedHandleErrorResponse( this: Record, diff --git a/tests/provider-402-guard.test.ts b/tests/provider-402-guard.test.ts index 36527f1..71e17a0 100644 --- a/tests/provider-402-guard.test.ts +++ b/tests/provider-402-guard.test.ts @@ -56,6 +56,22 @@ describe("classifyProvider402", () => { expect(classifyProvider402("gateway timeout")).toBe("unknown"); expect(classifyProvider402('{"detail":"something else"}')).toBe("unknown"); }); + + test("finds limit_source at realistic nesting depth (4 levels)", () => { + // Real 402 bodies nest 3-4 levels deep. + const body = JSON.stringify({ + detail: { error: { metadata: { limit_source: "openrouter_credits" } } }, + }); + expect(classifyProvider402(body)).toBe("provider_side"); + }); + + test("does not crash on adversarially deep nesting", () => { + // 50 levels deep — well beyond the depth limit. The field is never found, + // so it falls through to "unknown" instead of stack-overflowing. + let nested: Record = { limit_source: "openrouter_credits" }; + for (let i = 0; i < 50; i++) nested = { nested }; + expect(classifyProvider402(JSON.stringify(nested))).toBe("unknown"); + }); }); describe("provider 402 guard", () => { diff --git a/tests/sdk-mint-fallback-hardening.test.ts b/tests/sdk-mint-fallback-hardening.test.ts index 2bba59d..a6caf6c 100644 --- a/tests/sdk-mint-fallback-hardening.test.ts +++ b/tests/sdk-mint-fallback-hardening.test.ts @@ -481,6 +481,62 @@ describe("Hardened fallback — concurrency & idempotency", () => { }); }); +describe("Hardened fallback — SDK API drift detection", () => { + test("warns when topUp is missing from balance manager", () => { + // Simulate an SDK upgrade that renames or removes topUp. + const balanceManager = createBalanceManager(); + delete (balanceManager as { topUp?: unknown }).topUp; + const client = { getBalanceManager: () => balanceManager }; + + const warnings: string[] = []; + installMintFallbackTopUp( + client as never, + createCocodClient(["https://mint-a.example"]), + createWalletAdapter(), + { log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined }, + ); + + // Should have warned, not silently swallowed. + expect(warnings.some((w) => w.includes("topUp not found"))).toBe(true); + }); + + test("warns when createProviderToken is missing from balance manager", () => { + const balanceManager = createBalanceManager(); + delete (balanceManager as { createProviderToken?: unknown }).createProviderToken; + const client = { getBalanceManager: () => balanceManager }; + + const warnings: string[] = []; + installMintFallbackTopUp( + client as never, + createCocodClient(["https://mint-a.example"]), + createWalletAdapter(), + { log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined }, + ); + + expect(warnings.some((w) => w.includes("createProviderToken not found"))).toBe(true); + }); + + test("warns when _handleErrorResponse is missing from SDK client", () => { + // Simulate an SDK upgrade that renames _handleErrorResponse. + // createBalanceManager provides topUp + createProviderToken so those patches + // install cleanly, but the error-retry and 402-body-capture patches should warn. + const balanceManager = createBalanceManager(); + const client = { getBalanceManager: () => balanceManager }; + + const warnings: string[] = []; + installMintFallbackTopUp( + client as never, + createCocodClient(["https://mint-a.example"]), + createWalletAdapter(), + { log: () => undefined, warn: (msg: string) => warnings.push(msg), error: () => undefined }, + ); + + expect( + warnings.some((w) => w.includes("_handleErrorResponse not found")), + ).toBe(true); + }); +}); + describe("Hardened fallback — API key safety", () => { test("API key is not logged in error messages", async () => { const balanceManager = createBalanceManager();