diff --git a/CHANGELOG.md b/CHANGELOG.md index ad36a492610..94708fa7bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Docs: https://docs.openclaw.ai - Synology Chat: validate outbound webhook `file_url` values against the shared SSRF policy before forwarding to the NAS, rejecting malformed URLs, non-`http(s)` schemes, and private/blocked network targets so the NAS cannot be used as a confused deputy to fetch internal addresses. (#69784) Thanks @eleqtrizit. - LINE: validate outbound media URLs against the shared public-network guard before handing them to LINE, preserving arbitrary public HTTPS media while rejecting loopback, link-local, and private-network targets. - Gateway/Control UI: require gateway auth on the Control UI avatar route (`GET /avatar/` and `?meta=1` metadata) when auth is configured, matching the sibling assistant-media route, and propagate the existing gateway token through the UI avatar fetch (bearer header + authenticated blob URL) so authenticated dashboards still load local avatars. (#69775) +- Google Chat/auth: replace the Google auth `gaxios` shim with a scoped SSRF-guarded transport, validate service-account auth endpoints against trusted Google URLs, and let the plugin own its staged `gaxios` auth runtime instead of patching process-wide globals or the root CLI startup path. Thanks @vincentkoc. - Exec/allowlist: reject POSIX parameter expansion forms such as `$VAR`, `$?`, `$$`, `$1`, and `$@` inside unquoted heredocs during shell approval analysis, so these heredocs no longer pass allowlist review as plain text. (#69795) Thanks @drobison00. - Gateway/MCP loopback: derive owner-only tool visibility from distinct authenticated owner vs non-owner loopback bearers instead of the caller-controlled owner header, so non-owner MCP child processes cannot recover owner access by spoofing request metadata. (#69796) - GitHub Copilot: update the default Opus model from `claude-opus-4.6` to `claude-opus-4.7` after GitHub removed Copilot support for 4.6. (#69818) Thanks @shakkernerd. diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json index 23dfd85a4de..a0674aac3ac 100644 --- a/extensions/googlechat/package.json +++ b/extensions/googlechat/package.json @@ -5,7 +5,8 @@ "description": "OpenClaw Google Chat channel plugin", "type": "module", "dependencies": { - "google-auth-library": "^10.6.2", + "gaxios": "7.1.4", + "google-auth-library": "10.6.2", "zod": "^4.3.6" }, "devDependencies": { @@ -21,6 +22,9 @@ } }, "openclaw": { + "bundle": { + "stageRuntimeDependencies": true + }, "extensions": [ "./index.ts" ], diff --git a/extensions/googlechat/src/auth.ts b/extensions/googlechat/src/auth.ts index 1f1f6968069..54e68146500 100644 --- a/extensions/googlechat/src/auth.ts +++ b/extensions/googlechat/src/auth.ts @@ -1,7 +1,12 @@ -import { GoogleAuth, OAuth2Client } from "google-auth-library"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { fetchWithSsrFGuard } from "../runtime-api.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; +import { + __testing as googleAuthRuntimeTesting, + getGoogleAuthTransport, + loadGoogleAuthRuntime, + resolveValidatedGoogleChatCredentials, +} from "./google-auth.runtime.js"; const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot"; const CHAT_ISSUER = "chat@system.gserviceaccount.com"; @@ -12,10 +17,42 @@ const CHAT_CERTS_URL = // Size-capped to prevent unbounded growth in long-running deployments (#4948) const MAX_AUTH_CACHE_SIZE = 32; -const authCache = new Map(); -const verifyClient = new OAuth2Client(); +type GoogleAuthModule = typeof import("google-auth-library"); +type GoogleAuthRuntime = { + GoogleAuth: GoogleAuthModule["GoogleAuth"]; + OAuth2Client: GoogleAuthModule["OAuth2Client"]; +}; +type GoogleAuthInstance = InstanceType; +type GoogleAuthOptions = ConstructorParameters[0]; +type GoogleAuthTransport = NonNullable["clientOptions"] extends { + transporter?: infer T; +} + ? T + : never; +type OAuth2ClientInstance = InstanceType; + +const authCache = new Map(); let cachedCerts: { fetchedAt: number; certs: Record } | null = null; +let verifyClientPromise: Promise | null = null; + +async function getVerifyClient(): Promise { + if (!verifyClientPromise) { + verifyClientPromise = (async () => { + try { + const { OAuth2Client } = await loadGoogleAuthRuntime(); + // google-auth-library types its transporter through gaxios' CJS surface, + // while the plugin imports the ESM entrypoint directly. + const transporter = (await getGoogleAuthTransport()) as unknown as GoogleAuthTransport; + return new OAuth2Client({ transporter }); + } catch (error) { + verifyClientPromise = null; + throw error; + } + })(); + } + return await verifyClientPromise; +} function buildAuthKey(account: ResolvedGoogleChatAccount): string { if (account.credentialsFile) { @@ -27,12 +64,18 @@ function buildAuthKey(account: ResolvedGoogleChatAccount): string { return "none"; } -function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth { +async function getAuthInstance(account: ResolvedGoogleChatAccount): Promise { const key = buildAuthKey(account); const cached = authCache.get(account.accountId); if (cached && cached.key === key) { return cached.auth; } + const [{ GoogleAuth }, rawTransporter, credentials] = await Promise.all([ + loadGoogleAuthRuntime(), + getGoogleAuthTransport(), + resolveValidatedGoogleChatCredentials(account), + ]); + const transporter = rawTransporter as unknown as GoogleAuthTransport; const evictOldest = () => { if (authCache.size > MAX_AUTH_CACHE_SIZE) { @@ -43,21 +86,11 @@ function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth { } }; - if (account.credentialsFile) { - const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] }); - authCache.set(account.accountId, { key, auth }); - evictOldest(); - return auth; - } - - if (account.credentials) { - const auth = new GoogleAuth({ credentials: account.credentials, scopes: [CHAT_SCOPE] }); - authCache.set(account.accountId, { key, auth }); - evictOldest(); - return auth; - } - - const auth = new GoogleAuth({ scopes: [CHAT_SCOPE] }); + const auth = new GoogleAuth({ + ...(credentials ? { credentials } : {}), + clientOptions: { transporter }, + scopes: [CHAT_SCOPE], + }); authCache.set(account.accountId, { key, auth }); evictOldest(); return auth; @@ -66,7 +99,7 @@ function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth { export async function getGoogleChatAccessToken( account: ResolvedGoogleChatAccount, ): Promise { - const auth = getAuthInstance(account); + const auth = await getAuthInstance(account); const client = await auth.getClient(); const access = await client.getAccessToken(); const token = typeof access === "string" ? access : access?.token; @@ -117,6 +150,7 @@ export async function verifyGoogleChatRequest(params: { if (audienceType === "app-url") { try { + const verifyClient = await getVerifyClient(); const ticket = await verifyClient.verifyIdToken({ idToken: bearer, audience, @@ -153,6 +187,7 @@ export async function verifyGoogleChatRequest(params: { if (audienceType === "project-number") { try { + const verifyClient = await getVerifyClient(); const certs = await fetchChatCerts(); await verifyClient.verifySignedJwtWithCertsAsync(bearer, certs, audience, [CHAT_ISSUER]); return { ok: true }; @@ -165,3 +200,12 @@ export async function verifyGoogleChatRequest(params: { } export const GOOGLE_CHAT_SCOPE = CHAT_SCOPE; + +export const __testing = { + resetGoogleChatAuthForTests(): void { + authCache.clear(); + cachedCerts = null; + verifyClientPromise = null; + googleAuthRuntimeTesting.resetGoogleAuthRuntimeForTests(); + }, +}; diff --git a/extensions/googlechat/src/google-auth.runtime.test.ts b/extensions/googlechat/src/google-auth.runtime.test.ts new file mode 100644 index 00000000000..905a166ec9a --- /dev/null +++ b/extensions/googlechat/src/google-auth.runtime.test.ts @@ -0,0 +1,462 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + buildHostnameAllowlistPolicyFromSuffixAllowlist: vi.fn((hosts: string[]) => ({ + hostnameAllowlist: hosts, + })), + fetchWithSsrFGuard: vi.fn(), + gaxiosCtor: vi.fn(function MockGaxios(this: { defaults: Record }, defaults) { + this.defaults = defaults as Record; + }), +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ + buildHostnameAllowlistPolicyFromSuffixAllowlist: + mocks.buildHostnameAllowlistPolicyFromSuffixAllowlist, + fetchWithSsrFGuard: mocks.fetchWithSsrFGuard, +})); + +vi.mock("gaxios", () => ({ + Gaxios: mocks.gaxiosCtor, +})); + +let __testing: typeof import("./google-auth.runtime.js").__testing; +let createGoogleAuthFetch: typeof import("./google-auth.runtime.js").createGoogleAuthFetch; +let getGoogleAuthTransport: typeof import("./google-auth.runtime.js").getGoogleAuthTransport; +let resolveValidatedGoogleChatCredentials: typeof import("./google-auth.runtime.js").resolveValidatedGoogleChatCredentials; + +beforeAll(async () => { + ({ + __testing, + createGoogleAuthFetch, + getGoogleAuthTransport, + resolveValidatedGoogleChatCredentials, + } = await import("./google-auth.runtime.js")); +}); + +beforeEach(() => { + __testing.resetGoogleAuthRuntimeForTests(); + mocks.buildHostnameAllowlistPolicyFromSuffixAllowlist.mockClear(); + mocks.fetchWithSsrFGuard.mockReset(); + mocks.gaxiosCtor.mockClear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("googlechat google auth runtime", () => { + it("routes Google auth fetches through the SSRF guard and preserves explicit proxy mTLS", async () => { + const release = vi.fn(); + const injectedFetch = vi.fn(globalThis.fetch); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: new Response("ok", { status: 200 }), + release, + }); + + const guardedFetch = createGoogleAuthFetch(injectedFetch); + const response = await guardedFetch("https://oauth2.googleapis.com/token", { + agent: { proxy: new URL("http://proxy.example:8080") }, + cert: "CLIENT_CERT", + headers: { "content-type": "application/json" }, + key: "CLIENT_KEY", + method: "POST", + proxy: "http://proxy.example:8080", + } as RequestInit); + + expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({ + auditContext: "googlechat.auth.google-auth", + dispatcherPolicy: { + allowPrivateProxy: true, + mode: "explicit-proxy", + proxyTls: { + cert: "CLIENT_CERT", + key: "CLIENT_KEY", + }, + proxyUrl: "http://proxy.example:8080", + }, + fetchImpl: injectedFetch, + init: { + headers: { "content-type": "application/json" }, + method: "POST", + }, + policy: { + hostnameAllowlist: ["accounts.google.com", "googleapis.com"], + }, + url: "https://oauth2.googleapis.com/token", + }); + await expect(response.text()).resolves.toBe("ok"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("lets the guard resolve the ambient runtime fetch when no override is injected", async () => { + const release = vi.fn(); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: new Response("ok", { status: 200 }), + release, + }); + + const guardedFetch = createGoogleAuthFetch(); + await guardedFetch("https://oauth2.googleapis.com/token", { + method: "POST", + } as RequestInit); + + expect(mocks.fetchWithSsrFGuard.mock.calls[0]?.[0]).not.toHaveProperty("fetchImpl"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("keeps using the guard-selected runtime fetch even if global fetch changes later", async () => { + const release = vi.fn(); + const originalFetch = globalThis.fetch; + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: new Response("ok", { status: 200 }), + release, + }); + + const guardedFetch = createGoogleAuthFetch(); + (globalThis as Record).fetch = vi.fn(async () => new Response("patched")); + + try { + await guardedFetch("https://oauth2.googleapis.com/token", { + method: "POST", + } as RequestInit); + } finally { + (globalThis as Record).fetch = originalFetch; + } + + expect(mocks.fetchWithSsrFGuard.mock.calls[0]?.[0]).not.toHaveProperty("fetchImpl"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("bypasses explicit proxy when noProxy excludes the Google auth host", async () => { + const release = vi.fn(); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: new Response("ok", { status: 200 }), + release, + }); + + const guardedFetch = createGoogleAuthFetch(); + const response = await guardedFetch("https://oauth2.googleapis.com/token", { + cert: "CLIENT_CERT", + key: "CLIENT_KEY", + method: "POST", + noProxy: ["oauth2.googleapis.com"], + proxy: "http://proxy.example:8080", + } as RequestInit); + + expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({ + auditContext: "googlechat.auth.google-auth", + dispatcherPolicy: { + connect: { + cert: "CLIENT_CERT", + key: "CLIENT_KEY", + }, + mode: "direct", + }, + init: { + method: "POST", + }, + policy: { + hostnameAllowlist: ["accounts.google.com", "googleapis.com"], + }, + url: "https://oauth2.googleapis.com/token", + }); + await expect(response.text()).resolves.toBe("ok"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("preserves env-proxy transport when HTTPS proxy is configured", async () => { + const release = vi.fn(); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: new Response("ok", { status: 200 }), + release, + }); + vi.stubEnv("HTTPS_PROXY", "http://env-proxy.example:8080"); + vi.stubEnv("https_proxy", "http://lower-proxy.example:8080"); + + const guardedFetch = createGoogleAuthFetch(); + const response = await guardedFetch("https://oauth2.googleapis.com/token", { + cert: "CLIENT_CERT", + key: "CLIENT_KEY", + method: "POST", + } as RequestInit); + + expect(mocks.fetchWithSsrFGuard).toHaveBeenCalledWith({ + auditContext: "googlechat.auth.google-auth", + dispatcherPolicy: { + mode: "env-proxy", + proxyTls: { + cert: "CLIENT_CERT", + key: "CLIENT_KEY", + }, + }, + init: { + method: "POST", + }, + policy: { + hostnameAllowlist: ["accounts.google.com", "googleapis.com"], + }, + url: "https://oauth2.googleapis.com/token", + }); + await expect(response.text()).resolves.toBe("ok"); + expect(release).toHaveBeenCalledOnce(); + }); + + it("matches gaxios proxy env precedence for Google auth requests", () => { + vi.stubEnv("HTTP_PROXY", "http://upper-http-proxy.example:8080"); + vi.stubEnv("http_proxy", "http://lower-http-proxy.example:8080"); + vi.stubEnv("HTTPS_PROXY", "http://upper-https-proxy.example:8080"); + vi.stubEnv("https_proxy", "http://lower-https-proxy.example:8080"); + + expect(__testing.resolveGoogleAuthEnvProxyUrl("https")).toBe( + "http://upper-https-proxy.example:8080", + ); + expect(__testing.resolveGoogleAuthEnvProxyUrl("http")).toBe( + "http://upper-http-proxy.example:8080", + ); + }); + + it("releases guarded auth fetch resources even when callers do not consume the body", async () => { + const release = vi.fn(); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: new Response("ok", { status: 200 }), + release, + }); + + const guardedFetch = createGoogleAuthFetch(); + const response = await guardedFetch("https://oauth2.googleapis.com/token", { + method: "POST", + } as RequestInit); + + expect(release).toHaveBeenCalledOnce(); + await expect(response.text()).resolves.toBe("ok"); + }); + + it("rejects oversized guarded auth responses before buffering them into memory", async () => { + const release = vi.fn(); + let chunkIndex = 0; + const chunks = [new Uint8Array(700 * 1024), new Uint8Array(400 * 1024)]; + const body = new ReadableStream({ + pull(controller) { + if (chunkIndex < chunks.length) { + controller.enqueue(chunks[chunkIndex++]); + return; + } + controller.close(); + }, + }); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: new Response(body, { status: 200 }), + release, + }); + + const guardedFetch = createGoogleAuthFetch(); + + await expect( + guardedFetch("https://oauth2.googleapis.com/token", { + method: "POST", + } as RequestInit), + ).rejects.toThrow("Google auth response exceeds 1048576 bytes."); + expect(release).toHaveBeenCalledOnce(); + }); + + it("rejects non-stream guarded auth responses instead of buffering them unbounded", async () => { + const release = vi.fn(); + const arrayBuffer = vi.fn(async () => new ArrayBuffer(16)); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: { + arrayBuffer, + body: null, + headers: new Headers(), + status: 200, + statusText: "OK", + } as unknown as Response, + release, + }); + + const guardedFetch = createGoogleAuthFetch(); + + await expect( + guardedFetch("https://oauth2.googleapis.com/token", { + method: "POST", + } as RequestInit), + ).rejects.toThrow( + "Google auth response body stream unavailable; refusing to buffer unbounded response.", + ); + expect(arrayBuffer).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); + + it("rejects oversized auth responses from content-length before reading the body", async () => { + const release = vi.fn(); + const arrayBuffer = vi.fn(async () => new ArrayBuffer(16)); + mocks.fetchWithSsrFGuard.mockResolvedValueOnce({ + response: { + arrayBuffer, + body: null, + headers: new Headers({ + "content-length": String(2 * 1024 * 1024), + }), + status: 200, + statusText: "OK", + } as unknown as Response, + release, + }); + + const guardedFetch = createGoogleAuthFetch(); + + await expect( + guardedFetch("https://oauth2.googleapis.com/token", { + method: "POST", + } as RequestInit), + ).rejects.toThrow("Google auth response exceeds 1048576 bytes."); + expect(arrayBuffer).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); + + it("builds a scoped Gaxios transport without mutating global window", async () => { + const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); + Reflect.deleteProperty(globalThis as object, "window"); + try { + const transport = await getGoogleAuthTransport(); + + expect(mocks.gaxiosCtor).toHaveBeenCalledOnce(); + expect(transport).toMatchObject({ + defaults: { + fetchImplementation: expect.any(Function), + }, + }); + expect("window" in globalThis).toBe(false); + } finally { + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, "window", originalWindowDescriptor); + } + } + }); + + it("rejects service-account credentials that override Google auth endpoints", async () => { + await expect( + resolveValidatedGoogleChatCredentials({ + accountId: "default", + config: {}, + credentialSource: "inline", + credentials: { + client_email: "bot@example.iam.gserviceaccount.com", + private_key: "key", + token_uri: "https://evil.example/token", + type: "service_account", + }, + enabled: true, + }), + ).rejects.toThrow(/token_uri/); + }); + + it("reads and validates service-account files before passing them to google-auth", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "googlechat-auth-")); + try { + const credentialsPath = path.join(tempDir, "service-account.json"); + await fs.writeFile( + credentialsPath, + JSON.stringify({ + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + client_email: "bot@example.iam.gserviceaccount.com", + private_key: "key", + token_uri: "https://oauth2.googleapis.com/token", + type: "service_account", + universe_domain: "googleapis.com", + }), + "utf8", + ); + + await expect( + resolveValidatedGoogleChatCredentials({ + accountId: "default", + config: {}, + credentialSource: "file", + credentialsFile: credentialsPath, + enabled: true, + }), + ).resolves.toMatchObject({ + client_email: "bot@example.iam.gserviceaccount.com", + token_uri: "https://oauth2.googleapis.com/token", + type: "service_account", + }); + } finally { + await fs.rm(tempDir, { force: true, recursive: true }); + } + }); + + it("accepts symlinked service-account files used by secret mounts", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "googlechat-auth-link-")); + try { + const credentialsPath = path.join(tempDir, "service-account.json"); + const symlinkPath = path.join(tempDir, "service-account-link.json"); + await fs.writeFile( + credentialsPath, + JSON.stringify({ + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + client_email: "bot@example.iam.gserviceaccount.com", + private_key: "key", + token_uri: "https://oauth2.googleapis.com/token", + type: "service_account", + universe_domain: "googleapis.com", + }), + "utf8", + ); + try { + await fs.symlink(credentialsPath, symlinkPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EPERM") { + return; + } + throw error; + } + + await expect( + resolveValidatedGoogleChatCredentials({ + accountId: "default", + config: {}, + credentialSource: "file", + credentialsFile: symlinkPath, + enabled: true, + }), + ).resolves.toMatchObject({ + client_email: "bot@example.iam.gserviceaccount.com", + token_uri: "https://oauth2.googleapis.com/token", + type: "service_account", + }); + } finally { + await fs.rm(tempDir, { force: true, recursive: true }); + } + }); + + it("does not disclose raw credential paths or OS errors when file reads fail", async () => { + const missingPath = path.join(os.tmpdir(), "googlechat-auth-missing", "service-account.json"); + + await expect( + resolveValidatedGoogleChatCredentials({ + accountId: "default", + config: {}, + credentialSource: "file", + credentialsFile: missingPath, + enabled: true, + }), + ).rejects.toThrow("Failed to load Google Chat service account file."); + + await expect( + resolveValidatedGoogleChatCredentials({ + accountId: "default", + config: {}, + credentialSource: "file", + credentialsFile: missingPath, + enabled: true, + }), + ).rejects.not.toThrow(/ENOENT|service-account\.json|googlechat-auth-missing/); + }); +}); diff --git a/extensions/googlechat/src/google-auth.runtime.ts b/extensions/googlechat/src/google-auth.runtime.ts new file mode 100644 index 00000000000..a78ad915d05 --- /dev/null +++ b/extensions/googlechat/src/google-auth.runtime.ts @@ -0,0 +1,539 @@ +import fs from "node:fs/promises"; +import type { ConnectionOptions } from "node:tls"; +import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/ssrf-dispatcher"; +import { + buildHostnameAllowlistPolicyFromSuffixAllowlist, + fetchWithSsrFGuard, +} from "openclaw/plugin-sdk/ssrf-runtime"; +import { resolveUserPath } from "openclaw/plugin-sdk/text-runtime"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; + +type ProxyRule = RegExp | URL | string; +type TlsCert = ConnectionOptions["cert"]; +type TlsKey = ConnectionOptions["key"]; +type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; +type GoogleAuthModule = typeof import("google-auth-library"); +type GaxiosModule = typeof import("gaxios"); +type GoogleAuthRuntime = { + Gaxios: GaxiosModule["Gaxios"]; + GoogleAuth: GoogleAuthModule["GoogleAuth"]; + OAuth2Client: GoogleAuthModule["OAuth2Client"]; +}; +type GoogleAuthTransport = InstanceType; +type GuardedGoogleAuthRequestInit = RequestInit & { + agent?: unknown; + cert?: unknown; + dispatcher?: unknown; + fetchImplementation?: unknown; + key?: unknown; + noProxy?: unknown; + proxy?: unknown; +}; +type TlsOptions = { + cert?: TlsCert; + key?: TlsKey; +}; +type ProxyAgentLike = { + connectOpts?: TlsOptions; + proxy: URL; +}; +type TlsAgentLike = { + options?: TlsOptions; +}; +type GoogleChatServiceAccountCredentials = Record & { + auth_provider_x509_cert_url?: string; + auth_uri?: string; + client_email: string; + client_x509_cert_url?: string; + private_key: string; + token_uri?: string; + type?: string; + universe_domain?: string; +}; + +const GOOGLE_AUTH_ALLOWED_HOST_SUFFIXES = ["accounts.google.com", "googleapis.com"]; +const GOOGLE_AUTH_POLICY = buildHostnameAllowlistPolicyFromSuffixAllowlist( + GOOGLE_AUTH_ALLOWED_HOST_SUFFIXES, +); +const GOOGLE_AUTH_AUDIT_CONTEXT = "googlechat.auth.google-auth"; +const GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/auth"; +const GOOGLE_AUTH_PROVIDER_CERTS_URL = "https://www.googleapis.com/oauth2/v1/certs"; +const GOOGLE_AUTH_TOKEN_URI = "https://oauth2.googleapis.com/token"; +const GOOGLE_AUTH_UNIVERSE_DOMAIN = "googleapis.com"; +const GOOGLE_CLIENT_CERTS_URL_PREFIX = "https://www.googleapis.com/robot/v1/metadata/x509/"; +const MAX_GOOGLE_AUTH_RESPONSE_BYTES = 1024 * 1024; +const MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES = 64 * 1024; + +let googleAuthRuntimePromise: Promise | null = null; +let googleAuthTransportPromise: Promise | null = null; + +function asNullableObjectRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" ? (value as Record) : null; +} + +function hasProxyAgentShape(value: unknown): value is ProxyAgentLike { + const record = asNullableObjectRecord(value); + return record !== null && record.proxy instanceof URL; +} + +function hasTlsAgentShape(value: unknown): value is TlsAgentLike { + const record = asNullableObjectRecord(value); + return record !== null && asNullableObjectRecord(record.options) !== null; +} + +function resolveGoogleAuthAgent(init: GuardedGoogleAuthRequestInit, url: URL): unknown { + return typeof init.agent === "function" ? init.agent(url) : init.agent; +} + +function hasTlsOptions(options: TlsOptions): boolean { + return options.cert !== undefined || options.key !== undefined; +} + +function resolveGoogleAuthTlsOptions(init: GuardedGoogleAuthRequestInit, url: URL): TlsOptions { + const explicit = { + cert: init.cert as TlsCert | undefined, + key: init.key as TlsKey | undefined, + }; + if (hasTlsOptions(explicit)) { + return explicit; + } + + const agent = resolveGoogleAuthAgent(init, url); + if (hasProxyAgentShape(agent)) { + return { + cert: agent.connectOpts?.cert, + key: agent.connectOpts?.key, + }; + } + if (hasTlsAgentShape(agent)) { + return { + cert: agent.options?.cert, + key: agent.options?.key, + }; + } + return {}; +} + +function normalizeGoogleAuthProxyEnvValue(value: string | undefined): string | null | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resolveGoogleAuthEnvProxyUrl(protocol: "http" | "https"): string | undefined { + const httpProxy = + normalizeGoogleAuthProxyEnvValue(process.env.HTTP_PROXY) ?? + normalizeGoogleAuthProxyEnvValue(process.env.http_proxy); + const httpsProxy = + normalizeGoogleAuthProxyEnvValue(process.env.HTTPS_PROXY) ?? + normalizeGoogleAuthProxyEnvValue(process.env.https_proxy); + if (protocol === "https") { + return httpsProxy ?? httpProxy ?? undefined; + } + return httpProxy ?? undefined; +} + +function collectGoogleAuthNoProxyRules(noProxy: ProxyRule[] = []): ProxyRule[] { + const rules = [...noProxy]; + const envRules = (process.env.NO_PROXY ?? process.env.no_proxy)?.split(",") ?? []; + for (const rule of envRules) { + const trimmed = rule.trim(); + if (trimmed.length > 0) { + rules.push(trimmed); + } + } + return rules; +} + +function shouldBypassGoogleAuthProxy(url: URL, noProxy: ProxyRule[] = []): boolean { + for (const rule of collectGoogleAuthNoProxyRules(noProxy)) { + if (rule instanceof RegExp) { + if (rule.test(url.toString())) { + return true; + } + continue; + } + if (rule instanceof URL) { + if (rule.origin === url.origin) { + return true; + } + continue; + } + if (rule.startsWith("*.") || rule.startsWith(".")) { + const cleanedRule = rule.replace(/^\*\./, "."); + if (url.hostname.endsWith(cleanedRule)) { + return true; + } + continue; + } + if (rule === url.origin || rule === url.hostname || rule === url.href) { + return true; + } + } + return false; +} + +function readGoogleAuthProxyUrl(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (value instanceof URL) { + return value.toString(); + } + return undefined; +} + +function readOptionalTrimmedString( + record: Record, + fieldName: string, +): string | undefined { + const value = record[fieldName]; + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`Google Chat service account field "${fieldName}" must be a string`); + } + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`Google Chat service account field "${fieldName}" cannot be empty`); + } + return trimmed; +} + +function readRequiredTrimmedString(record: Record, fieldName: string): string { + return ( + readOptionalTrimmedString(record, fieldName) ?? + (() => { + throw new Error(`Google Chat service account is missing "${fieldName}"`); + })() + ); +} + +function assertExactUrlField( + record: Record, + fieldName: string, + expectedUrl: string, +): void { + const value = readOptionalTrimmedString(record, fieldName); + if (!value) { + return; + } + if (value !== expectedUrl) { + throw new Error( + `Google Chat service account field "${fieldName}" must be ${expectedUrl}, got ${value}`, + ); + } +} + +function assertUrlPrefixField( + record: Record, + fieldName: string, + expectedPrefix: string, +): void { + const value = readOptionalTrimmedString(record, fieldName); + if (!value) { + return; + } + if (!value.startsWith(expectedPrefix)) { + throw new Error( + `Google Chat service account field "${fieldName}" must start with ${expectedPrefix}, got ${value}`, + ); + } +} + +function validateGoogleChatServiceAccountCredentials( + credentials: Record, +): GoogleChatServiceAccountCredentials { + const type = readOptionalTrimmedString(credentials, "type"); + if (type && type !== "service_account") { + throw new Error(`Google Chat credentials must use service_account auth, got "${type}" instead`); + } + + readRequiredTrimmedString(credentials, "client_email"); + readRequiredTrimmedString(credentials, "private_key"); + + const universeDomain = readOptionalTrimmedString(credentials, "universe_domain"); + if (universeDomain && universeDomain !== GOOGLE_AUTH_UNIVERSE_DOMAIN) { + throw new Error( + `Google Chat service account field "universe_domain" must be ${GOOGLE_AUTH_UNIVERSE_DOMAIN}, got ${universeDomain}`, + ); + } + + assertExactUrlField(credentials, "auth_uri", GOOGLE_AUTH_URI); + assertExactUrlField(credentials, "auth_provider_x509_cert_url", GOOGLE_AUTH_PROVIDER_CERTS_URL); + assertExactUrlField(credentials, "token_uri", GOOGLE_AUTH_TOKEN_URI); + assertUrlPrefixField(credentials, "client_x509_cert_url", GOOGLE_CLIENT_CERTS_URL_PREFIX); + + return credentials as GoogleChatServiceAccountCredentials; +} + +async function readCredentialsFile(filePath: string): Promise> { + const resolvedPath = resolveUserPath(filePath); + if (!resolvedPath) { + throw new Error("Google Chat service account file path is empty"); + } + + let handle: Awaited> | null = null; + try { + handle = await fs.open(resolvedPath, "r"); + } catch { + throw new Error("Failed to load Google Chat service account file."); + } + + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new Error("Google Chat service account file must be a regular file."); + } + if (stat.size > MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES) { + throw new Error( + `Google Chat service account file exceeds ${MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES} bytes.`, + ); + } + + let raw: string; + try { + raw = await handle.readFile({ encoding: "utf8" }); + } catch { + throw new Error("Failed to load Google Chat service account file."); + } + if (Buffer.byteLength(raw, "utf8") > MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES) { + throw new Error( + `Google Chat service account file exceeds ${MAX_GOOGLE_CHAT_SERVICE_ACCOUNT_FILE_BYTES} bytes.`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Invalid Google Chat service account JSON."); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Google Chat service account file must contain a JSON object."); + } + return parsed as Record; + } finally { + await handle.close().catch(() => {}); + } +} + +function sanitizeGoogleAuthInit(init?: RequestInit): RequestInit | undefined { + if (!init) { + return undefined; + } + const nextInit = { ...(init as GuardedGoogleAuthRequestInit) }; + delete nextInit.agent; + delete nextInit.cert; + delete nextInit.dispatcher; + delete nextInit.fetchImplementation; + delete nextInit.key; + delete nextInit.noProxy; + delete nextInit.proxy; + return nextInit; +} + +function resolveGoogleAuthDispatcherPolicy( + input: RequestInfo | URL, + init?: RequestInit, +): { + dispatcherPolicy?: PinnedDispatcherPolicy; + init?: RequestInit; +} { + const requestUrl = + input instanceof Request + ? new URL(input.url) + : new URL(typeof input === "string" ? input : input.toString()); + const nextInit = sanitizeGoogleAuthInit(init); + const googleAuthInit = (init ?? {}) as GuardedGoogleAuthRequestInit; + const tlsOptions = resolveGoogleAuthTlsOptions(googleAuthInit, requestUrl); + const proxyBypassed = shouldBypassGoogleAuthProxy( + requestUrl, + Array.isArray(googleAuthInit.noProxy) ? (googleAuthInit.noProxy as ProxyRule[]) : [], + ); + const agent = resolveGoogleAuthAgent(googleAuthInit, requestUrl); + const explicitProxy = + readGoogleAuthProxyUrl(googleAuthInit.proxy) ?? + (hasProxyAgentShape(agent) ? agent.proxy.toString() : undefined); + + if (!proxyBypassed && explicitProxy) { + return { + dispatcherPolicy: { + allowPrivateProxy: true, + mode: "explicit-proxy", + ...(hasTlsOptions(tlsOptions) ? { proxyTls: { ...tlsOptions } } : {}), + proxyUrl: explicitProxy, + }, + init: nextInit, + }; + } + + const envProxyUrl = proxyBypassed + ? undefined + : resolveGoogleAuthEnvProxyUrl(requestUrl.protocol === "http:" ? "http" : "https"); + if (envProxyUrl) { + return { + dispatcherPolicy: { + mode: "env-proxy", + ...(hasTlsOptions(tlsOptions) ? { proxyTls: { ...tlsOptions } } : {}), + }, + init: nextInit, + }; + } + + if (hasTlsOptions(tlsOptions)) { + return { + dispatcherPolicy: { + connect: { ...tlsOptions }, + mode: "direct", + }, + init: nextInit, + }; + } + + return { init: nextInit }; +} + +export function createGoogleAuthFetch(baseFetch?: FetchLike): FetchLike { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = input instanceof Request ? input.url : String(input); + const guardedOptions = resolveGoogleAuthDispatcherPolicy(input, init); + const { response, release } = await fetchWithSsrFGuard({ + auditContext: GOOGLE_AUTH_AUDIT_CONTEXT, + dispatcherPolicy: guardedOptions.dispatcherPolicy, + init: guardedOptions.init, + policy: GOOGLE_AUTH_POLICY, + url, + ...(baseFetch ? { fetchImpl: baseFetch } : {}), + }); + try { + const body = await readGoogleAuthResponseBytes(response); + const bufferedBody = Uint8Array.from(body); + return new Response(bufferedBody.buffer, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + } finally { + await release(); + } + }; +} + +async function readGoogleAuthResponseBytes(response: Response): Promise { + const contentLengthHeader = response.headers.get("content-length"); + if (contentLengthHeader) { + const contentLength = Number(contentLengthHeader); + if (Number.isFinite(contentLength) && contentLength > MAX_GOOGLE_AUTH_RESPONSE_BYTES) { + throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`); + } + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error( + "Google auth response body stream unavailable; refusing to buffer unbounded response.", + ); + } + + const chunks: Uint8Array[] = []; + let total = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + total += value.byteLength; + if (total > MAX_GOOGLE_AUTH_RESPONSE_BYTES) { + try { + await reader.cancel("Google auth response exceeded buffer limit"); + } catch { + // Ignore cancellation errors; the caller still releases the dispatcher. + } + throw new Error(`Google auth response exceeds ${MAX_GOOGLE_AUTH_RESPONSE_BYTES} bytes.`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export async function loadGoogleAuthRuntime(): Promise { + if (!googleAuthRuntimePromise) { + googleAuthRuntimePromise = (async () => { + try { + const [googleAuthModule, gaxiosModule] = await Promise.all([ + import("google-auth-library"), + import("gaxios"), + ]); + return { + Gaxios: gaxiosModule.Gaxios, + GoogleAuth: googleAuthModule.GoogleAuth, + OAuth2Client: googleAuthModule.OAuth2Client, + }; + } catch (error) { + googleAuthRuntimePromise = null; + throw error; + } + })(); + } + return await googleAuthRuntimePromise; +} + +export async function getGoogleAuthTransport(): Promise { + if (!googleAuthTransportPromise) { + googleAuthTransportPromise = (async () => { + try { + const { Gaxios } = await loadGoogleAuthRuntime(); + return new Gaxios({ + fetchImplementation: createGoogleAuthFetch(), + }); + } catch (error) { + googleAuthTransportPromise = null; + throw error; + } + })(); + } + return await googleAuthTransportPromise; +} + +export async function resolveValidatedGoogleChatCredentials( + account: ResolvedGoogleChatAccount, +): Promise { + if (account.credentials) { + return validateGoogleChatServiceAccountCredentials(account.credentials); + } + if (account.credentialsFile) { + const fileCredentials = await readCredentialsFile(account.credentialsFile); + return validateGoogleChatServiceAccountCredentials(fileCredentials); + } + return null; +} + +export const __testing = { + resetGoogleAuthRuntimeForTests(): void { + googleAuthRuntimePromise = null; + googleAuthTransportPromise = null; + }, + resolveGoogleAuthEnvProxyUrl, + validateGoogleChatServiceAccountCredentials, +}; diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index 724cd8aa399..05f9e13a6ae 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -10,10 +10,17 @@ import { } from "./targets.js"; const mocks = vi.hoisted(() => ({ + buildHostnameAllowlistPolicyFromSuffixAllowlist: vi.fn((hosts: string[]) => ({ + hostnameAllowlist: hosts, + })), fetchWithSsrFGuard: vi.fn(async (params: { url: string; init?: RequestInit }) => ({ response: await fetch(params.url, params.init), release: async () => {}, })), + googleAuthCtor: vi.fn(), + gaxiosCtor: vi.fn(), + getAccessToken: vi.fn().mockResolvedValue({ token: "access-token" }), + oauthCtor: vi.fn(), verifySignedJwtWithCertsAsync: vi.fn(), verifyIdToken: vi.fn(), getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"), @@ -21,13 +28,38 @@ const mocks = vi.hoisted(() => ({ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => { return { + buildHostnameAllowlistPolicyFromSuffixAllowlist: + mocks.buildHostnameAllowlistPolicyFromSuffixAllowlist, fetchWithSsrFGuard: mocks.fetchWithSsrFGuard, }; }); +vi.mock("gaxios", () => ({ + Gaxios: class { + defaults: unknown; + + constructor(defaults?: unknown) { + this.defaults = defaults; + mocks.gaxiosCtor(defaults); + } + }, +})); + vi.mock("google-auth-library", () => ({ - GoogleAuth: function GoogleAuth() {}, + GoogleAuth: class { + constructor(options?: unknown) { + mocks.googleAuthCtor(options); + } + + getClient = vi.fn().mockResolvedValue({ + getAccessToken: mocks.getAccessToken, + }); + }, OAuth2Client: class { + constructor(options?: unknown) { + mocks.oauthCtor(options); + } + verifyIdToken = mocks.verifyIdToken; verifySignedJwtWithCertsAsync = mocks.verifySignedJwtWithCertsAsync; }, @@ -41,7 +73,8 @@ vi.mock("./auth.js", async () => { }; }); -const { verifyGoogleChatRequest } = await import("./auth.js"); +const authActual = await vi.importActual("./auth.js"); +const { __testing: authTesting, getGoogleChatAccessToken, verifyGoogleChatRequest } = authActual; const account = { accountId: "default", @@ -138,6 +171,7 @@ describe("isSenderAllowed", () => { describe("downloadGoogleChatMedia", () => { afterEach(() => { + authTesting.resetGoogleChatAuthForTests(); mocks.fetchWithSsrFGuard.mockClear(); vi.unstubAllGlobals(); }); @@ -178,6 +212,7 @@ describe("downloadGoogleChatMedia", () => { describe("sendGoogleChatMessage", () => { afterEach(() => { + authTesting.resetGoogleChatAuthForTests(); mocks.fetchWithSsrFGuard.mockClear(); vi.unstubAllGlobals(); }); @@ -221,6 +256,53 @@ function mockTicket(payload: Record) { } describe("verifyGoogleChatRequest", () => { + afterEach(() => { + authTesting.resetGoogleChatAuthForTests(); + mocks.getAccessToken.mockClear(); + mocks.gaxiosCtor.mockClear(); + mocks.googleAuthCtor.mockClear(); + mocks.oauthCtor.mockClear(); + }); + + it("injects a scoped transporter into GoogleAuth access-token clients", async () => { + await expect( + getGoogleChatAccessToken({ + ...account, + credentials: { + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + client_email: "bot@example.iam.gserviceaccount.com", + private_key: "key", + token_uri: "https://oauth2.googleapis.com/token", + type: "service_account", + universe_domain: "googleapis.com", + }, + }), + ).resolves.toBe("access-token"); + + const googleAuthOptions = mocks.googleAuthCtor.mock.calls[0]?.[0] as { + clientOptions?: { transporter?: { defaults?: { fetchImplementation?: unknown } } }; + credentials?: { client_email?: string; token_uri?: string }; + }; + + expect(mocks.gaxiosCtor).toHaveBeenCalledOnce(); + expect(googleAuthOptions).toMatchObject({ + clientOptions: { + transporter: { + defaults: { + fetchImplementation: expect.any(Function), + }, + }, + }, + credentials: { + client_email: "bot@example.iam.gserviceaccount.com", + token_uri: "https://oauth2.googleapis.com/token", + }, + }); + expect(mocks.getAccessToken).toHaveBeenCalledOnce(); + expect("window" in globalThis).toBe(false); + }); + it("accepts Google Chat app-url tokens from the Chat issuer", async () => { mocks.verifyIdToken.mockReset(); mockTicket({ @@ -235,6 +317,17 @@ describe("verifyGoogleChatRequest", () => { audience: "https://example.com/googlechat", }), ).resolves.toEqual({ ok: true }); + + const oauthOptions = mocks.oauthCtor.mock.calls[0]?.[0] as { + transporter?: { defaults?: { fetchImplementation?: unknown } }; + }; + expect(oauthOptions).toMatchObject({ + transporter: { + defaults: { + fetchImplementation: expect.any(Function), + }, + }, + }); }); it("rejects add-on tokens when no principal binding is configured", async () => { diff --git a/package.json b/package.json index 934376ff0fd..8b662272c37 100644 --- a/package.json +++ b/package.json @@ -1547,7 +1547,6 @@ "dotenv": "^17.4.2", "express": "^5.2.1", "file-type": "22.0.1", - "gaxios": "7.1.4", "https-proxy-agent": "^9.0.0", "ipaddr.js": "^2.3.0", "jiti": "^2.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83ad3526310..5425cc39874 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,9 +104,6 @@ importers: file-type: specifier: 22.0.1 version: 22.0.1 - gaxios: - specifier: 7.1.4 - version: 7.1.4 https-proxy-agent: specifier: ^9.0.0 version: 9.0.0 @@ -594,8 +591,11 @@ importers: extensions/googlechat: dependencies: + gaxios: + specifier: 7.1.4 + version: 7.1.4 google-auth-library: - specifier: ^10.6.2 + specifier: 10.6.2 version: 10.6.2 zod: specifier: ^4.3.6 diff --git a/src/entry.ts b/src/entry.ts index c38f69b1449..2d0d7f5df5f 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -43,9 +43,6 @@ if ( ) { // Imported as a dependency — skip all entry-point side effects. } else { - const { installGaxiosFetchCompat } = await import("./infra/gaxios-fetch-compat.js"); - - await installGaxiosFetchCompat(); process.title = "openclaw"; ensureOpenClawExecMarkerOnProcess(); installProcessWarningFilter(); diff --git a/src/index.ts b/src/index.ts index 5d126ea642a..e21ecbfdaa6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,6 @@ import { isMainModule } from "./infra/is-main.js"; import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js"; type LegacyCliDeps = { - installGaxiosFetchCompat: () => Promise; runCli: (argv: string[]) => Promise; }; @@ -36,11 +35,8 @@ export let saveSessionStore: LibraryExports["saveSessionStore"]; export let waitForever: LibraryExports["waitForever"]; async function loadLegacyCliDeps(): Promise { - const [{ installGaxiosFetchCompat }, { runCli }] = await Promise.all([ - import("./infra/gaxios-fetch-compat.js"), - import("./cli/run-main.js"), - ]); - return { installGaxiosFetchCompat, runCli }; + const { runCli } = await import("./cli/run-main.js"); + return { runCli }; } // Legacy direct file entrypoint only. Package root exports now live in library.ts. @@ -48,8 +44,7 @@ export async function runLegacyCliEntry( argv: string[] = process.argv, deps?: LegacyCliDeps, ): Promise { - const { installGaxiosFetchCompat, runCli } = deps ?? (await loadLegacyCliDeps()); - await installGaxiosFetchCompat(); + const { runCli } = deps ?? (await loadLegacyCliDeps()); await runCli(argv); } diff --git a/src/infra/gaxios-fetch-compat.test.ts b/src/infra/gaxios-fetch-compat.test.ts deleted file mode 100644 index b3714d80af3..00000000000 --- a/src/infra/gaxios-fetch-compat.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { createRequire } from "node:module"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__"; -type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; -let ProxyAgent: typeof import("undici").ProxyAgent; -let __testing: typeof import("./gaxios-fetch-compat.js").__testing; -let createGaxiosCompatFetch: typeof import("./gaxios-fetch-compat.js").createGaxiosCompatFetch; -let installGaxiosFetchCompat: typeof import("./gaxios-fetch-compat.js").installGaxiosFetchCompat; - -beforeAll(async () => { - const require = createRequire(import.meta.url); - ({ ProxyAgent } = require("undici") as typeof import("undici")); - ({ __testing, createGaxiosCompatFetch, installGaxiosFetchCompat } = - await import("./gaxios-fetch-compat.js")); -}); - -beforeEach(() => { - vi.useRealTimers(); - vi.doUnmock("undici"); - __testing.resetGaxiosFetchCompatForTests(); -}); - -describe("gaxios fetch compat", () => { - afterEach(() => { - Reflect.deleteProperty(globalThis as object, TEST_GAXIOS_CONSTRUCTOR_OVERRIDE); - __testing.resetGaxiosFetchCompatForTests(); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it("uses native fetch without defining window or importing node-fetch", async () => { - type MockRequestConfig = RequestInit & { - fetchImplementation?: FetchLike; - responseType?: string; - url: string; - }; - let MockGaxiosCtor!: new () => { - request(config: MockRequestConfig): Promise<{ data: string } & object>; - }; - const fetchMock = vi.fn(async () => { - return new Response("ok", { - headers: { "content-type": "text/plain" }, - status: 200, - }); - }); - - vi.stubGlobal("fetch", fetchMock); - class MockGaxios { - async _defaultAdapter(config: MockRequestConfig): Promise { - const fetchImplementation = config.fetchImplementation ?? fetch; - return await fetchImplementation(config.url, config); - } - - async request(config: MockRequestConfig) { - const response = await this._defaultAdapter(config); - return { - ...(response as object), - data: await response.text(), - }; - } - } - MockGaxiosCtor = MockGaxios; - - (globalThis as Record)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = MockGaxios; - - await installGaxiosFetchCompat(); - - const res = await new MockGaxiosCtor().request({ - responseType: "text", - url: "https://example.com", - }); - - expect(res.data).toBe("ok"); - expect(fetchMock).toHaveBeenCalledOnce(); - expect("window" in globalThis).toBe(false); - }); - - it("falls back to a legacy window fetch shim when gaxios is unavailable", async () => { - const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, "window"); - vi.stubGlobal("fetch", vi.fn()); - Reflect.deleteProperty(globalThis as object, "window"); - (globalThis as Record)[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE] = null; - try { - await expect(installGaxiosFetchCompat()).resolves.toBeUndefined(); - expect((globalThis as { window?: { fetch?: FetchLike } }).window?.fetch).toBe(fetch); - await expect(installGaxiosFetchCompat()).resolves.toBeUndefined(); - } finally { - Reflect.deleteProperty(globalThis as object, "window"); - if (originalWindowDescriptor) { - Object.defineProperty(globalThis, "window", originalWindowDescriptor); - } - } - }); - - it("translates proxy-agent-like inputs into undici dispatchers for native fetch", async () => { - const fetchMock = vi.fn(async () => { - return new Response("ok", { - headers: { "content-type": "text/plain" }, - status: 200, - }); - }); - const compatFetch = createGaxiosCompatFetch(fetchMock); - await compatFetch("https://example.com", { - agent: { proxy: new URL("http://proxy.example:8080") }, - } as RequestInit); - - expect(fetchMock).toHaveBeenCalledOnce(); - const [, init] = fetchMock.mock.calls[0] ?? []; - - expect(init).not.toHaveProperty("agent"); - expect((init as { dispatcher?: unknown })?.dispatcher).toBeInstanceOf(ProxyAgent); - }); -}); diff --git a/src/infra/gaxios-fetch-compat.ts b/src/infra/gaxios-fetch-compat.ts deleted file mode 100644 index ba315ba2dba..00000000000 --- a/src/infra/gaxios-fetch-compat.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { createRequire } from "node:module"; -import type { ConnectionOptions } from "node:tls"; -import { pathToFileURL } from "node:url"; -import type { Dispatcher } from "undici"; -import { asNullableObjectRecord } from "../shared/record-coerce.js"; - -type ProxyRule = RegExp | URL | string; -type TlsCert = ConnectionOptions["cert"]; -type TlsKey = ConnectionOptions["key"]; -type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; - -type GaxiosFetchRequestInit = RequestInit & { - agent?: unknown; - cert?: TlsCert; - dispatcher?: Dispatcher; - fetchImplementation?: FetchLike; - key?: TlsKey; - noProxy?: ProxyRule[]; - proxy?: string | URL; -}; - -type ProxyAgentLike = { - connectOpts?: { cert?: TlsCert; key?: TlsKey }; - proxy: URL; -}; - -type TlsAgentLike = { - options?: { cert?: TlsCert; key?: TlsKey }; -}; - -type GaxiosPrototype = { - _defaultAdapter: (this: unknown, config: GaxiosFetchRequestInit) => Promise; -}; - -type GaxiosConstructor = { - prototype: GaxiosPrototype; -}; - -const TEST_GAXIOS_CONSTRUCTOR_OVERRIDE = "__OPENCLAW_TEST_GAXIOS_CONSTRUCTOR__"; - -let installState: "not-installed" | "installing" | "shimmed" | "installed" = "not-installed"; - -type UndiciRuntimeDeps = { - UndiciAgent: typeof import("undici").Agent; - ProxyAgent: typeof import("undici").ProxyAgent; -}; - -function hasDispatcher(value: unknown): value is Dispatcher { - const record = asNullableObjectRecord(value); - return record !== null && typeof record.dispatch === "function"; -} - -function hasProxyAgentShape(value: unknown): value is ProxyAgentLike { - const record = asNullableObjectRecord(value); - return record !== null && record.proxy instanceof URL; -} - -function hasTlsAgentShape(value: unknown): value is TlsAgentLike { - const record = asNullableObjectRecord(value); - return record !== null && asNullableObjectRecord(record.options) !== null; -} - -function resolveTlsOptions( - init: GaxiosFetchRequestInit, - url: URL, -): { cert?: TlsCert; key?: TlsKey } { - const explicit = { - cert: init.cert, - key: init.key, - }; - if (explicit.cert !== undefined || explicit.key !== undefined) { - return explicit; - } - - const agent = typeof init.agent === "function" ? init.agent(url) : init.agent; - if (hasProxyAgentShape(agent)) { - return { - cert: agent.connectOpts?.cert, - key: agent.connectOpts?.key, - }; - } - if (hasTlsAgentShape(agent)) { - return { - cert: agent.options?.cert, - key: agent.options?.key, - }; - } - return {}; -} - -function urlMayUseProxy(url: URL, noProxy: ProxyRule[] = []): boolean { - const rules = [...noProxy]; - const envRules = (process.env.NO_PROXY ?? process.env.no_proxy)?.split(",") ?? []; - for (const rule of envRules) { - const trimmed = rule.trim(); - if (trimmed.length > 0) { - rules.push(trimmed); - } - } - - for (const rule of rules) { - if (rule instanceof RegExp) { - if (rule.test(url.toString())) { - return false; - } - continue; - } - if (rule instanceof URL) { - if (rule.origin === url.origin) { - return false; - } - continue; - } - if (rule.startsWith("*.") || rule.startsWith(".")) { - const cleanedRule = rule.replace(/^\*\./, "."); - if (url.hostname.endsWith(cleanedRule)) { - return false; - } - continue; - } - if (rule === url.origin || rule === url.hostname || rule === url.href) { - return false; - } - } - - return true; -} - -function resolveProxyUri(init: GaxiosFetchRequestInit, url: URL): string | undefined { - if (init.proxy) { - const proxyUri = String(init.proxy); - return urlMayUseProxy(url, init.noProxy) ? proxyUri : undefined; - } - - const envProxy = - process.env.HTTPS_PROXY ?? - process.env.https_proxy ?? - process.env.HTTP_PROXY ?? - process.env.http_proxy; - if (!envProxy) { - return undefined; - } - - return urlMayUseProxy(url, init.noProxy) ? envProxy : undefined; -} - -function loadUndiciRuntimeDeps(): UndiciRuntimeDeps { - const require = createRequire(import.meta.url); - const undici = require("undici") as typeof import("undici"); - return { - ProxyAgent: undici.ProxyAgent, - UndiciAgent: undici.Agent, - }; -} - -function buildDispatcher(init: GaxiosFetchRequestInit, url: URL): Dispatcher | undefined { - if (init.dispatcher) { - return init.dispatcher; - } - - const agent = typeof init.agent === "function" ? init.agent(url) : init.agent; - if (hasDispatcher(agent)) { - return agent; - } - - const { cert, key } = resolveTlsOptions(init, url); - const proxyUri = - resolveProxyUri(init, url) ?? (hasProxyAgentShape(agent) ? String(agent.proxy) : undefined); - if (proxyUri) { - const { ProxyAgent } = loadUndiciRuntimeDeps(); - return new ProxyAgent({ - requestTls: cert !== undefined || key !== undefined ? { cert, key } : undefined, - uri: proxyUri, - }); - } - - if (cert !== undefined || key !== undefined) { - const { UndiciAgent } = loadUndiciRuntimeDeps(); - return new UndiciAgent({ - connect: { cert, key }, - }); - } - - return undefined; -} - -function isModuleNotFoundError(err: unknown): err is NodeJS.ErrnoException { - const record = asNullableObjectRecord(err); - return ( - record !== null && - (record.code === "ERR_MODULE_NOT_FOUND" || record.code === "MODULE_NOT_FOUND") - ); -} - -function hasGaxiosConstructorShape(value: unknown): value is GaxiosConstructor { - return ( - typeof value === "function" && - "prototype" in value && - asNullableObjectRecord(value.prototype) !== null && - typeof value.prototype._defaultAdapter === "function" - ); -} - -function getTestGaxiosConstructorOverride(): GaxiosConstructor | null | undefined { - const testGlobal = globalThis as Record; - if (!Object.prototype.hasOwnProperty.call(testGlobal, TEST_GAXIOS_CONSTRUCTOR_OVERRIDE)) { - return undefined; - } - const override = testGlobal[TEST_GAXIOS_CONSTRUCTOR_OVERRIDE]; - if (override === null) { - return null; - } - if (hasGaxiosConstructorShape(override)) { - return override; - } - throw new Error("invalid gaxios test constructor override"); -} - -function isDirectGaxiosImportMiss(err: unknown): boolean { - if (!isModuleNotFoundError(err)) { - return false; - } - return ( - typeof err.message === "string" && - (err.message.includes("Cannot find package 'gaxios'") || - err.message.includes("Cannot find module 'gaxios'")) - ); -} - -async function loadGaxiosConstructor(): Promise { - const testOverride = getTestGaxiosConstructorOverride(); - if (testOverride !== undefined) { - return testOverride; - } - - try { - const require = createRequire(import.meta.url); - const resolvedPath = require.resolve("gaxios"); - const mod = await import(pathToFileURL(resolvedPath).href); - const candidate = asNullableObjectRecord(mod)?.Gaxios; - if (!hasGaxiosConstructorShape(candidate)) { - throw new Error("gaxios: missing Gaxios export"); - } - return candidate; - } catch (err) { - if (isDirectGaxiosImportMiss(err)) { - return null; - } - throw err; - } -} - -function installLegacyWindowFetchShim(): void { - if ( - typeof globalThis.fetch !== "function" || - typeof (globalThis as Record).window !== "undefined" - ) { - return; - } - (globalThis as Record).window = { fetch: globalThis.fetch }; -} - -export function createGaxiosCompatFetch( - baseFetch: FetchLike = globalThis.fetch.bind(globalThis), -): FetchLike { - return async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const gaxiosInit = (init ?? {}) as GaxiosFetchRequestInit; - const requestUrl = - input instanceof Request - ? new URL(input.url) - : new URL(typeof input === "string" ? input : input.toString()); - const dispatcher = buildDispatcher(gaxiosInit, requestUrl); - - const nextInit: RequestInit = { ...gaxiosInit }; - delete (nextInit as GaxiosFetchRequestInit).agent; - delete (nextInit as GaxiosFetchRequestInit).cert; - delete (nextInit as GaxiosFetchRequestInit).fetchImplementation; - delete (nextInit as GaxiosFetchRequestInit).key; - delete (nextInit as GaxiosFetchRequestInit).noProxy; - delete (nextInit as GaxiosFetchRequestInit).proxy; - - if (dispatcher) { - (nextInit as RequestInit & { dispatcher: Dispatcher }).dispatcher = dispatcher; - } - - return baseFetch(input, nextInit); - }; -} - -export async function installGaxiosFetchCompat(): Promise { - if (installState !== "not-installed" || typeof globalThis.fetch !== "function") { - return; - } - - installState = "installing"; - - try { - const Gaxios = await loadGaxiosConstructor(); - if (!Gaxios) { - installLegacyWindowFetchShim(); - installState = "shimmed"; - return; - } - - const prototype = Gaxios.prototype; - const originalDefaultAdapter = prototype._defaultAdapter; - const compatFetch = createGaxiosCompatFetch(); - - prototype._defaultAdapter = function patchedDefaultAdapter( - this: unknown, - config: GaxiosFetchRequestInit, - ): Promise { - if (config.fetchImplementation) { - return originalDefaultAdapter.call(this, config); - } - return originalDefaultAdapter.call(this, { - ...config, - fetchImplementation: compatFetch, - }); - }; - - installState = "installed"; - } catch (err) { - installState = "not-installed"; - throw err; - } -} - -export const __testing = { - resetGaxiosFetchCompatForTests(): void { - installState = "not-installed"; - }, -}; diff --git a/src/plugins/contracts/package-manifest.contract.test.ts b/src/plugins/contracts/package-manifest.contract.test.ts index b7daba37abc..459d6b2be97 100644 --- a/src/plugins/contracts/package-manifest.contract.test.ts +++ b/src/plugins/contracts/package-manifest.contract.test.ts @@ -24,7 +24,7 @@ const packageManifestContractTests: PackageManifestContractParams[] = [ { pluginId: "google", pluginLocalRuntimeDeps: ["@google/genai"] }, { pluginId: "googlechat", - pluginLocalRuntimeDeps: ["google-auth-library"], + pluginLocalRuntimeDeps: ["gaxios", "google-auth-library"], minHostVersionBaseline: "2026.3.22", }, { pluginId: "irc", minHostVersionBaseline: "2026.3.22" },