mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
462
extensions/googlechat/src/google-auth.runtime.test.ts
Normal file
462
extensions/googlechat/src/google-auth.runtime.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
539
extensions/googlechat/src/google-auth.runtime.ts
Normal file
539
extensions/googlechat/src/google-auth.runtime.ts
Normal 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,
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
11
src/index.ts
11
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<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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
},
|
||||
};
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user