fix(googlechat): harden google auth transport (#69812)

* fix(googlechat): localize google auth gaxios compat

* fix(googlechat): declare undici for staged runtime deps

* fix(googlechat): harden google auth transport

* fix(googlechat): narrow credential file reads

* fix(googlechat): preserve auth proxy transport

* fix(googlechat): allow symlinked auth files

* fix(googlechat): atomically load auth files

* fix(googlechat): eagerly buffer auth responses

* fix(googlechat): cap auth response buffering

* fix(googlechat): pin staged auth runtime deps

* fix(googlechat): buffer auth responses as array buffers

* Update CHANGELOG.md

* fix(googlechat): reject unstreamed auth responses

* fix(googlechat): use ambient fetch for auth transport

* fix(googlechat): keep guarded auth fetch on runtime path

* fix(googlechat): align staged zod range

* chore(lockfile): sync googlechat zod spec
This commit is contained in:
Vincent Koc
2026-04-21 22:40:57 -07:00
committed by GitHub
parent d94a981a33
commit 6d6845ea9d
13 changed files with 1174 additions and 487 deletions

View File

@@ -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/<agentId>` 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.

View File

@@ -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"
],

View File

@@ -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<string, { key: string; auth: GoogleAuth }>();
const verifyClient = new OAuth2Client();
type GoogleAuthModule = typeof import("google-auth-library");
type GoogleAuthRuntime = {
GoogleAuth: GoogleAuthModule["GoogleAuth"];
OAuth2Client: GoogleAuthModule["OAuth2Client"];
};
type GoogleAuthInstance = InstanceType<GoogleAuthRuntime["GoogleAuth"]>;
type GoogleAuthOptions = ConstructorParameters<GoogleAuthRuntime["GoogleAuth"]>[0];
type GoogleAuthTransport = NonNullable<GoogleAuthOptions>["clientOptions"] extends {
transporter?: infer T;
}
? T
: never;
type OAuth2ClientInstance = InstanceType<GoogleAuthRuntime["OAuth2Client"]>;
const authCache = new Map<string, { key: string; auth: GoogleAuthInstance }>();
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
let verifyClientPromise: Promise<OAuth2ClientInstance> | null = null;
async function getVerifyClient(): Promise<OAuth2ClientInstance> {
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<GoogleAuthInstance> {
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<string> {
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();
},
};

View File

@@ -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<string, unknown> }, defaults) {
this.defaults = defaults as Record<string, unknown>;
}),
}));
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<string, unknown>).fetch = vi.fn(async () => new Response("patched"));
try {
await guardedFetch("https://oauth2.googleapis.com/token", {
method: "POST",
} as RequestInit);
} finally {
(globalThis as Record<string, unknown>).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<Uint8Array>({
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/);
});
});

View File

@@ -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<Response>;
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<GaxiosModule["Gaxios"]>;
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<string, unknown> & {
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<GoogleAuthRuntime> | null = null;
let googleAuthTransportPromise: Promise<GoogleAuthTransport> | null = null;
function asNullableObjectRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" ? (value as Record<string, unknown>) : 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<string, unknown>,
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<string, unknown>, fieldName: string): string {
return (
readOptionalTrimmedString(record, fieldName) ??
(() => {
throw new Error(`Google Chat service account is missing "${fieldName}"`);
})()
);
}
function assertExactUrlField(
record: Record<string, unknown>,
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<string, unknown>,
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<string, unknown>,
): 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<Record<string, unknown>> {
const resolvedPath = resolveUserPath(filePath);
if (!resolvedPath) {
throw new Error("Google Chat service account file path is empty");
}
let handle: Awaited<ReturnType<typeof fs.open>> | 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<string, unknown>;
} 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<Response> => {
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<Uint8Array> {
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<GoogleAuthRuntime> {
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<GoogleAuthTransport> {
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<GoogleChatServiceAccountCredentials | null> {
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,
};

View File

@@ -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<typeof import("./auth.js")>("./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<string, unknown>) {
}
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 () => {

View File

@@ -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",

8
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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();

View File

@@ -6,7 +6,6 @@ import { isMainModule } from "./infra/is-main.js";
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
type LegacyCliDeps = {
installGaxiosFetchCompat: () => Promise<void>;
runCli: (argv: string[]) => Promise<void>;
};
@@ -36,11 +35,8 @@ export let saveSessionStore: LibraryExports["saveSessionStore"];
export let waitForever: LibraryExports["waitForever"];
async function loadLegacyCliDeps(): Promise<LegacyCliDeps> {
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<void> {
const { installGaxiosFetchCompat, runCli } = deps ?? (await loadLegacyCliDeps());
await installGaxiosFetchCompat();
const { runCli } = deps ?? (await loadLegacyCliDeps());
await runCli(argv);
}

View File

@@ -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<Response>;
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<FetchLike>(async () => {
return new Response("ok", {
headers: { "content-type": "text/plain" },
status: 200,
});
});
vi.stubGlobal("fetch", fetchMock);
class MockGaxios {
async _defaultAdapter(config: MockRequestConfig): Promise<Response> {
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<string, unknown>)[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<FetchLike>());
Reflect.deleteProperty(globalThis as object, "window");
(globalThis as Record<string, unknown>)[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<FetchLike>(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);
});
});

View File

@@ -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<Response>;
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<unknown>;
};
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<string, unknown>;
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<GaxiosConstructor | null> {
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<string, unknown>).window !== "undefined"
) {
return;
}
(globalThis as Record<string, unknown>).window = { fetch: globalThis.fetch };
}
export function createGaxiosCompatFetch(
baseFetch: FetchLike = globalThis.fetch.bind(globalThis),
): FetchLike {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
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<void> {
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<unknown> {
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";
},
};

View File

@@ -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" },