diff --git a/src/secrets/resolve.test.ts b/src/secrets/resolve.test.ts index fe53b6f73..990ce508b 100644 --- a/src/secrets/resolve.test.ts +++ b/src/secrets/resolve.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resolveSecretRefString, resolveSecretRefValue } from "./resolve.js"; @@ -15,6 +15,7 @@ describe("secret ref resolver", () => { const cleanupRoots: string[] = []; afterEach(async () => { + vi.restoreAllMocks(); while (cleanupRoots.length > 0) { const root = cleanupRoots.pop(); if (!root) { @@ -280,6 +281,56 @@ describe("secret ref resolver", () => { expect(value).toBe("raw-token-value"); }); + it("times out file provider reads when timeoutMs elapses", async () => { + if (process.platform === "win32") { + return; + } + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-secrets-resolve-timeout-")); + cleanupRoots.push(root); + const filePath = path.join(root, "secrets.json"); + await writeSecureFile( + filePath, + JSON.stringify({ + providers: { + openai: { + apiKey: "sk-file-value", + }, + }, + }), + ); + + const originalReadFile = fs.readFile.bind(fs); + vi.spyOn(fs, "readFile").mockImplementation((( + targetPath: Parameters[0], + options?: Parameters[1], + ) => { + if (typeof targetPath === "string" && targetPath === filePath) { + return new Promise(() => {}); + } + return originalReadFile(targetPath, options); + }) as typeof fs.readFile); + + await expect( + resolveSecretRefString( + { source: "file", provider: "filemain", id: "/providers/openai/apiKey" }, + { + config: { + secrets: { + providers: { + filemain: { + source: "file", + path: filePath, + mode: "jsonPointer", + timeoutMs: 5, + }, + }, + }, + }, + }, + ), + ).rejects.toThrow('File provider "filemain" timed out'); + }); + it("rejects misconfigured provider source mismatches", async () => { await expect( resolveSecretRefValue( diff --git a/src/secrets/resolve.ts b/src/secrets/resolve.ts index 5060d99b8..bb83654a4 100644 --- a/src/secrets/resolve.ts +++ b/src/secrets/resolve.ts @@ -188,11 +188,20 @@ async function readFileProviderPayload(params: { DEFAULT_FILE_TIMEOUT_MS, ); const maxBytes = normalizePositiveInt(params.providerConfig.maxBytes, DEFAULT_FILE_MAX_BYTES); - const timeoutHandle = setTimeout(() => { - // noop marker to keep timeout behavior explicit and deterministic - }, timeoutMs); + const abortController = new AbortController(); + const timeoutErrorMessage = `File provider "${params.providerName}" timed out after ${timeoutMs}ms.`; + let timeoutHandle: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutHandle = setTimeout(() => { + abortController.abort(); + reject(new Error(timeoutErrorMessage)); + }, timeoutMs); + }); try { - const payload = await fs.readFile(filePath); + const payload = await Promise.race([ + fs.readFile(filePath, { signal: abortController.signal }), + timeoutPromise, + ]); if (payload.byteLength > maxBytes) { throw new Error(`File provider "${params.providerName}" exceeded maxBytes (${maxBytes}).`); } @@ -205,8 +214,15 @@ async function readFileProviderPayload(params: { throw new Error(`File provider "${params.providerName}" payload is not a JSON object.`); } return parsed; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new Error(timeoutErrorMessage, { cause: error }); + } + throw error; } finally { - clearTimeout(timeoutHandle); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } } })();