diff --git a/extensions/anthropic-vertex/region.adc.test.ts b/extensions/anthropic-vertex/region.adc.test.ts index dd8de591d18..4a841f01c2e 100644 --- a/extensions/anthropic-vertex/region.adc.test.ts +++ b/extensions/anthropic-vertex/region.adc.test.ts @@ -28,6 +28,10 @@ vi.mock("node:fs", async () => { }; }); +vi.mock("openclaw/plugin-sdk/secret-file-runtime", () => ({ + tryReadSecretFileSync: (pathname: string) => readFileSyncMock(pathname, "utf8"), +})); + import { hasAnthropicVertexAvailableAuth, resolveAnthropicVertexProjectId } from "./region.js"; describe("anthropic-vertex ADC reads", () => { diff --git a/extensions/anthropic-vertex/region.ts b/extensions/anthropic-vertex/region.ts index 319c52c317d..3c2276d1451 100644 --- a/extensions/anthropic-vertex/region.ts +++ b/extensions/anthropic-vertex/region.ts @@ -2,17 +2,19 @@ * Anthropic Vertex region, project, and ADC auth detection helpers. They keep * credential probing local to the provider plugin. */ -import { readFileSync } from "node:fs"; import { homedir, platform } from "node:os"; import { join } from "node:path"; +import type { GoogleAuthOptions } from "google-auth-library"; import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http"; +import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; const ANTHROPIC_VERTEX_DEFAULT_REGION = "global"; const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/; const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; +const ANTHROPIC_VERTEX_ADC_FILE_MAX_BYTES = 1024 * 1024; -type AdcProjectFile = { +type AnthropicVertexAdcCredentials = NonNullable & { project_id?: unknown; quota_project_id?: unknown; }; @@ -107,14 +109,27 @@ function resolveAnthropicVertexAdcCredentialsPathCandidate( return resolveAnthropicVertexDefaultAdcPath(env); } -function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean { +export function resolveAnthropicVertexAdcCredentials( + env: NodeJS.ProcessEnv = process.env, +): AnthropicVertexAdcCredentials | undefined { const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env); - if (!credentialsPath) { - return false; + const text = tryReadSecretFileSync(credentialsPath, "Anthropic Vertex ADC credentials", { + maxBytes: ANTHROPIC_VERTEX_ADC_FILE_MAX_BYTES, + rejectHardlinks: false, + }); + if (!text) { + return undefined; } + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Anthropic Vertex ADC credentials must be a JSON object: ${credentialsPath}`); + } + return parsed as AnthropicVertexAdcCredentials; +} + +function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolean { try { - readFileSync(credentialsPath, "utf8"); - return true; + return resolveAnthropicVertexAdcCredentials(env) !== undefined; } catch { return false; } @@ -123,12 +138,11 @@ function canReadAnthropicVertexAdc(env: NodeJS.ProcessEnv = process.env): boolea function resolveAnthropicVertexProjectIdFromAdc( env: NodeJS.ProcessEnv = process.env, ): string | undefined { - const credentialsPath = resolveAnthropicVertexAdcCredentialsPathCandidate(env); - if (!credentialsPath) { - return undefined; - } try { - const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile; + const parsed = resolveAnthropicVertexAdcCredentials(env); + if (!parsed) { + return undefined; + } return ( normalizeOptionalSecretInput(parsed.project_id) || normalizeOptionalSecretInput(parsed.quota_project_id) diff --git a/extensions/anthropic-vertex/stream-runtime.test.ts b/extensions/anthropic-vertex/stream-runtime.test.ts index db4cf476536..00a56299ae8 100644 --- a/extensions/anthropic-vertex/stream-runtime.test.ts +++ b/extensions/anthropic-vertex/stream-runtime.test.ts @@ -1,6 +1,9 @@ // Anthropic Vertex tests cover stream runtime plugin behavior. import { once } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; import { createAssistantMessageEventStream, type Model } from "openclaw/plugin-sdk/llm"; import { beforeAll, describe, expect, it, vi } from "vitest"; import type { AnthropicVertexStreamDeps } from "./stream-runtime.js"; @@ -173,6 +176,44 @@ describe("createAnthropicVertexStreamFn", () => { }); }); + it("passes bounded ADC credentials to google-auth-library", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-anthropic-vertex-stream-adc-")); + const credentialsPath = path.join(tempDir, "application_default_credentials.json"); + const credentials = { + type: "service_account", + project_id: "vertex-project", + }; + const json = JSON.stringify(credentials); + const env = { GOOGLE_APPLICATION_CREDENTIALS: credentialsPath } as NodeJS.ProcessEnv; + const { deps, googleAuthCtorMock } = createStreamDeps(); + try { + writeFileSync(credentialsPath, `${json}${" ".repeat(1024 * 1024 - json.length)}`); + createAnthropicVertexStreamFnForModel({}, env, deps); + expect(googleAuthCtorMock).toHaveBeenCalledWith({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + credentials, + clientOptions: { + transporterOptions: { fetchImplementation: expect.any(Function) }, + }, + }); + + writeFileSync(credentialsPath, `${json}${" ".repeat(1024 * 1024 + 1 - json.length)}`); + let readError: unknown; + try { + createAnthropicVertexStreamFnForModel({}, env, deps); + } catch (error) { + readError = error; + } + expect(readError).toMatchObject({ + name: "FsSafeError", + code: "too-large", + message: `Anthropic Vertex ADC credentials file at ${credentialsPath} exceeds 1048576 bytes.`, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("uses provider-local proxy-aware fetch without mutating the global window", async () => { const { deps, anthropicVertexCtorMock, googleAuthCtorMock, googleAuthClient } = createStreamDeps(); diff --git a/extensions/anthropic-vertex/stream-runtime.ts b/extensions/anthropic-vertex/stream-runtime.ts index b5d2f92c11a..139ea4e128a 100644 --- a/extensions/anthropic-vertex/stream-runtime.ts +++ b/extensions/anthropic-vertex/stream-runtime.ts @@ -23,7 +23,11 @@ import { supportsClaudeNativeXhighEffort, } from "openclaw/plugin-sdk/provider-model-shared"; import { EnvHttpProxyAgent, fetch as undiciFetch } from "undici"; -import { resolveAnthropicVertexClientRegion, resolveAnthropicVertexProjectId } from "./region.js"; +import { + resolveAnthropicVertexAdcCredentials, + resolveAnthropicVertexClientRegion, + resolveAnthropicVertexProjectId, +} from "./region.js"; const GOOGLE_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; @@ -156,11 +160,14 @@ export function createAnthropicVertexStreamFn( region: string, baseURL?: string, deps: AnthropicVertexStreamDeps = defaultAnthropicVertexStreamDeps, + env: NodeJS.ProcessEnv = process.env, ): StreamFn { // GoogleAuth carries clientOptions into file-backed ADC clients. Keep the // proxy-aware transport provider-local; a window shim changes detection globally. + const adcConfig = resolveAnthropicVertexAdcCredentials(env); const googleAuth = new deps.GoogleAuth({ scopes: [GOOGLE_CLOUD_PLATFORM_SCOPE], + ...(adcConfig ? { credentials: adcConfig } : {}), clientOptions: { transporterOptions: { fetchImplementation: googleAuthFetch }, }, @@ -291,5 +298,6 @@ export function createAnthropicVertexStreamFnForModel( }), resolveAnthropicVertexSdkBaseUrl(model.baseUrl), deps, + env, ); } diff --git a/extensions/google/transport-stream.test.ts b/extensions/google/transport-stream.test.ts index 9506e30edcb..4edabb0b40a 100644 --- a/extensions/google/transport-stream.test.ts +++ b/extensions/google/transport-stream.test.ts @@ -1111,6 +1111,36 @@ describe("google transport stream", () => { expect(tokenFetchMock).not.toHaveBeenCalled(); }); + it("bounds Google Vertex ADC files before google-auth-library reads them", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-adc-file-")); + const credentialsPath = path.join(tempDir, "application_default_credentials.json"); + const credentials = { + type: "service_account", + client_email: "vertex@example.iam.gserviceaccount.com", + }; + const json = JSON.stringify(credentials); + await writeFile(credentialsPath, `${json}${" ".repeat(1024 * 1024 - json.length)}`, "utf8"); + vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", credentialsPath); + googleAuthGetAccessTokenMock.mockResolvedValueOnce("ya29.file-token"); + + await expect(resolveGoogleVertexAuthorizedUserHeaders(vi.fn())).resolves.toEqual({ + Authorization: "Bearer ya29.file-token", + }); + expect(googleAuthMock).toHaveBeenCalledWith({ + scopes: ["https://www.googleapis.com/auth/cloud-platform"], + credentials, + clientOptions: { transporterOptions: { timeout: 30_000 } }, + }); + + resetGoogleVertexAdcState(); + await writeFile(credentialsPath, `${json}${" ".repeat(1024 * 1024 + 1 - json.length)}`, "utf8"); + await expect(resolveGoogleVertexAuthorizedUserHeaders(vi.fn())).rejects.toMatchObject({ + name: "FsSafeError", + code: "too-large", + message: `Google Vertex ADC credentials file at ${credentialsPath} exceeds 1048576 bytes.`, + }); + }); + it("bounds google-auth-library ADC token resolution at the Vertex owner", async () => { const tempDir = await mkdtemp( path.join(os.tmpdir(), "openclaw-google-vertex-authlib-timeout-"), diff --git a/extensions/google/vertex-adc.ts b/extensions/google/vertex-adc.ts index 794b0922850..18bb9c667fc 100644 --- a/extensions/google/vertex-adc.ts +++ b/extensions/google/vertex-adc.ts @@ -1,9 +1,9 @@ // Google plugin module implements vertex adc behavior. -import { existsSync, readFileSync } from "node:fs"; -import { readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { gunzipSync } from "node:zlib"; +import type { GoogleAuthOptions } from "google-auth-library"; import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared"; import { asDateTimestampMs, @@ -11,6 +11,7 @@ import { resolveExpiresAtMsFromDurationSeconds, } from "openclaw/plugin-sdk/number-runtime"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; +import { readSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime"; @@ -149,19 +150,25 @@ function resolveGoogleApplicationCredentialsPath( return existsSync(appDataFallback) ? appDataFallback : undefined; } -async function readGoogleAuthorizedUserCredentials( - credentialsPath: string, -): Promise { - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(credentialsPath, "utf8")) as unknown; - } catch { - return undefined; - } +type GoogleAdcConfig = NonNullable; +const GOOGLE_VERTEX_ADC_FILE_MAX_BYTES = 1024 * 1024; + +function readGoogleAdcCredentials(adcPath: string): GoogleAdcConfig { + const text = readSecretFileSync(adcPath, "Google Vertex ADC credentials", { + maxBytes: GOOGLE_VERTEX_ADC_FILE_MAX_BYTES, + rejectHardlinks: false, + }); + const parsed = JSON.parse(text) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined; + throw new Error(`Google Vertex ADC credentials must be a JSON object: ${adcPath}`); } - const record = parsed as Record; + return parsed as GoogleAdcConfig; +} + +function resolveGoogleAuthorizedUserCredentials( + adcConfig: GoogleAdcConfig, +): GoogleAuthorizedUserCredentials | undefined { + const record = adcConfig as Record; if (record.type !== "authorized_user") { return undefined; } @@ -175,11 +182,7 @@ async function readGoogleAuthorizedUserCredentials( function readGoogleAdcCredentialsTypeSync(credentialsPath: string): string | undefined { try { - const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined; - } - const type = (parsed as { type?: unknown }).type; + const type = (readGoogleAdcCredentials(credentialsPath) as { type?: unknown }).type; return typeof type === "string" ? type : undefined; } catch { return undefined; @@ -353,7 +356,9 @@ function shouldGunzipGoogleOauthTokenResponse( .includes("gzip"); } -async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise { +async function resolveGoogleVertexAccessTokenViaGoogleAuth( + adcConfig?: GoogleAdcConfig, +): Promise { // Lazy-import + cache so we don't pay the google-auth-library load cost on // gateway startup; only when we actually need a non-authorized_user token. if (!cachedGoogleAuthClient) { @@ -367,6 +372,7 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise { // It also caches tokens internally and refreshes before expiry. return new GoogleAuth({ scopes: [GOOGLE_VERTEX_OAUTH_SCOPE], + ...(adcConfig ? { credentials: adcConfig } : {}), // Best-effort cancellation for clients that use the shared transporter. // WIF STS and GCE metadata need the owner-level deadline below. clientOptions: { @@ -442,13 +448,15 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise { export async function resolveGoogleVertexAuthorizedUserHeaders( fetchImpl?: typeof fetch, ): Promise> { - const credentialsPath = resolveGoogleApplicationCredentialsPath(); - if (credentialsPath) { - const credentials = await readGoogleAuthorizedUserCredentials(credentialsPath); - if (credentials) { + const adcPath = resolveGoogleApplicationCredentialsPath(); + let adcConfig: GoogleAdcConfig | undefined; + if (adcPath) { + adcConfig = readGoogleAdcCredentials(adcPath); + const userAdc = resolveGoogleAuthorizedUserCredentials(adcConfig); + if (userAdc) { const token = await refreshGoogleVertexAuthorizedUserAccessToken({ - credentialsPath, - credentials, + credentialsPath: adcPath, + credentials: userAdc, fetchImpl, }); return { Authorization: `Bearer ${token}` }; @@ -457,6 +465,6 @@ export async function resolveGoogleVertexAuthorizedUserHeaders( // No file-based authorized_user ADC. Fall back to google-auth-library which // handles GKE Workload Identity (metadata server), Workload Identity // Federation (external_account), and service-account keys. - const token = await resolveGoogleVertexAccessTokenViaGoogleAuth(); + const token = await resolveGoogleVertexAccessTokenViaGoogleAuth(adcConfig); return { Authorization: `Bearer ${token}` }; } diff --git a/extensions/qa-lab/src/mantis/discord-smoke.runtime.test.ts b/extensions/qa-lab/src/mantis/discord-smoke.runtime.test.ts index 98905415080..56076279d65 100644 --- a/extensions/qa-lab/src/mantis/discord-smoke.runtime.test.ts +++ b/extensions/qa-lab/src/mantis/discord-smoke.runtime.test.ts @@ -129,6 +129,40 @@ describe("mantis discord smoke runtime", () => { expect(await fs.readFile(result.reportPath, "utf8")).not.toContain("test-token"); }); + it("bounds Mantis Discord token files", async () => { + await fs.writeFile(tokenFile, "x".repeat(4 * 1024), "utf8"); + const boundaryResult = await runMantisDiscordSmoke({ + repoRoot, + outputDir: ".artifacts/qa-e2e/mantis/token-boundary", + tokenFile, + skipPost: true, + env: { + OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867", + OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449", + }, + }); + expect(boundaryResult.status).toBe("pass"); + const fetchCallsAtBoundary = fetchWithSsrFGuard.mock.calls.length; + + await fs.writeFile(tokenFile, "x".repeat(4 * 1024 + 1), "utf8"); + const oversizedResult = await runMantisDiscordSmoke({ + repoRoot, + outputDir: ".artifacts/qa-e2e/mantis/token-oversized", + tokenFile, + skipPost: true, + env: { + OPENCLAW_QA_DISCORD_GUILD_ID: "1456350064065904867", + OPENCLAW_QA_DISCORD_CHANNEL_ID: "1456744319972282449", + }, + }); + + expect(oversizedResult.status).toBe("fail"); + expect(fetchWithSsrFGuard.mock.calls).toHaveLength(fetchCallsAtBoundary); + expect(await fs.readFile(path.join(oversizedResult.outputDir, "error.txt"), "utf8")).toContain( + `Mantis Discord token file at ${tokenFile} exceeds 4096 bytes.`, + ); + }); + it("supports visibility-only smoke runs", async () => { const result = await runMantisDiscordSmoke({ repoRoot, diff --git a/extensions/qa-lab/src/mantis/discord-smoke.runtime.ts b/extensions/qa-lab/src/mantis/discord-smoke.runtime.ts index d4be422c844..48ceaa71fa9 100644 --- a/extensions/qa-lab/src/mantis/discord-smoke.runtime.ts +++ b/extensions/qa-lab/src/mantis/discord-smoke.runtime.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; +import { readSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js"; import { isTruthyOptIn, trimToValue } from "../mantis-options.runtime.js"; @@ -102,6 +103,7 @@ const DEFAULT_GUILD_ID_ENV = "OPENCLAW_QA_DISCORD_GUILD_ID"; const DEFAULT_CHANNEL_ID_ENV = "OPENCLAW_QA_DISCORD_CHANNEL_ID"; const QA_REDACT_PUBLIC_METADATA_ENV = "OPENCLAW_QA_REDACT_PUBLIC_METADATA"; const DISCORD_API_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; +const MANTIS_DISCORD_TOKEN_FILE_MAX_BYTES = 4 * 1024; function assertDiscordSnowflake(value: string, label: string) { if (!/^\d{17,20}$/u.test(value)) { @@ -110,11 +112,10 @@ function assertDiscordSnowflake(value: string, label: string) { } async function readTokenFile(filePath: string) { - const token = trimToValue(await fs.readFile(filePath, "utf8")); - if (!token) { - throw new Error(`Mantis Discord token file is empty: ${filePath}`); - } - return token; + return readSecretFileSync(filePath, "Mantis Discord token", { + maxBytes: MANTIS_DISCORD_TOKEN_FILE_MAX_BYTES, + rejectHardlinks: false, + }); } async function resolveMantisDiscordToken(opts: MantisDiscordSmokeOptions) { diff --git a/scripts/gh-read.ts b/scripts/gh-read.ts index 555b0b2453f..6352ebadd38 100644 --- a/scripts/gh-read.ts +++ b/scripts/gh-read.ts @@ -1,8 +1,8 @@ // Gh Read script supports OpenClaw repository automation. import { spawnSync } from "node:child_process"; import { createPrivateKey, createSign } from "node:crypto"; -import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { readSecretFileSync } from "@openclaw/fs-safe/secret"; import { expectDefined } from "../packages/normalization-core/src/expect.js"; import { readBoundedResponseText } from "./lib/bounded-response.ts"; import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts"; @@ -21,6 +21,7 @@ const API_VERSION = "2022-11-28"; const DEFAULT_GITHUB_FETCH_TIMEOUT_MS = 30_000; const GITHUB_ERROR_BODY_MAX_CHARS = 4096; const GITHUB_JSON_BODY_MAX_BYTES = 1024 * 1024; +const GITHUB_APP_PRIVATE_KEY_MAX_BYTES = 64 * 1024; const DEFAULT_READ_PERMISSION_KEYS = [ "actions", "checks", @@ -352,6 +353,13 @@ async function createInstallationToken( return tokenResponse.token; } +export function readGitHubAppPrivateKey(filePath: string): string { + return readSecretFileSync(filePath, "GitHub App private key", { + maxBytes: GITHUB_APP_PRIVATE_KEY_MAX_BYTES, + rejectHardlinks: false, + }); +} + async function main() { if (process.argv.length <= 2) { fail( @@ -362,7 +370,7 @@ async function main() { const ghArgs = process.argv.slice(2); const appId = readRequiredEnv(APP_ID_ENV); const privateKeyPath = readRequiredEnv(KEY_FILE_ENV); - const privateKeyPem = readFileSync(privateKeyPath, "utf8"); + const privateKeyPem = readGitHubAppPrivateKey(privateKeyPath); const repo = resolveRepo(ghArgs); const appJwt = createAppJwt(appId, privateKeyPem); const installation = await resolveInstallation(appJwt, repo); diff --git a/test/scripts/gh-read.test.ts b/test/scripts/gh-read.test.ts index 6e54d1ba419..a0857397f46 100644 --- a/test/scripts/gh-read.test.ts +++ b/test/scripts/gh-read.test.ts @@ -1,5 +1,8 @@ // Gh Read tests cover gh read script behavior. import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildReadPermissions, @@ -7,6 +10,7 @@ import { normalizeRepo, parsePermissionKeys, parseRepoArg, + readGitHubAppPrivateKey, readBoundedGitHubErrorText, readBoundedGitHubJson, resolveGitHubFetchTimeoutMs, @@ -78,6 +82,30 @@ describe("gh-read helpers", () => { ]); }); + it("bounds GitHub App private key files", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-gh-read-key-")); + const privateKeyPath = path.join(tempDir, "app.pem"); + try { + writeFileSync(privateKeyPath, "x".repeat(64 * 1024)); + expect(readGitHubAppPrivateKey(privateKeyPath)).toHaveLength(64 * 1024); + + writeFileSync(privateKeyPath, "x".repeat(64 * 1024 + 1)); + let readError: unknown; + try { + readGitHubAppPrivateKey(privateKeyPath); + } catch (error) { + readError = error; + } + expect(readError).toMatchObject({ + name: "FsSafeError", + code: "too-large", + message: `GitHub App private key file at ${privateKeyPath} exceeds 65536 bytes.`, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("aborts stalled GitHub API fetches at the request timeout", async () => { let signal: AbortSignal | undefined; let markFetchStarted!: () => void;