diff --git a/core/package.json b/core/package.json index 678f8c0c9..c76811aaa 100644 --- a/core/package.json +++ b/core/package.json @@ -71,6 +71,7 @@ "zod-to-json-schema": "^3.25.1" }, "devDependencies": { + "@google-cloud/eventarc-publishing": "^4.3.0", "@mikro-orm/sqlite": "^6.6.6", "@types/adm-zip": "^0.5.8", "@types/express": "^4.17.25", @@ -78,10 +79,16 @@ "openapi-types": "^12.1.3" }, "peerDependencies": { + "@google-cloud/eventarc-publishing": "^4.3.0", "@mikro-orm/mariadb": "^6.6.6", "@mikro-orm/mssql": "^6.6.6", "@mikro-orm/mysql": "^6.6.6", "@mikro-orm/postgresql": "^6.6.6", "@mikro-orm/sqlite": "^6.6.6" + }, + "peerDependenciesMeta": { + "@google-cloud/eventarc-publishing": { + "optional": true + } } } diff --git a/core/src/index.ts b/core/src/index.ts index 242f18fca..0285bec8d 100644 --- a/core/src/index.ts +++ b/core/src/index.ts @@ -57,6 +57,7 @@ export { export {RunSkillScriptTool} from './tools/skill/run_skill_script_tool.js'; export * from './integrations/agent_registry/agent_registry.js'; +export * from './integrations/eventarc/eventarc_toolset.js'; export * from './telemetry/google_cloud.js'; export * from './telemetry/setup.js'; export * from './tools/mcp/load_mcp_resource_tool.js'; diff --git a/core/src/integrations/eventarc/client.ts b/core/src/integrations/eventarc/client.ts new file mode 100644 index 000000000..6e8b1bde2 --- /dev/null +++ b/core/src/integrations/eventarc/client.ts @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {PublisherClient} from '@google-cloud/eventarc-publishing'; +import {AuthClient} from 'google-auth-library'; +import {createHash} from 'node:crypto'; + +import {logger} from '../../utils/logger.js'; +import {version} from '../../version.js'; +import {EventarcCredentialsConfig, resolveScopes} from './config.js'; + +/** Constructor of the optional Eventarc publishing client. */ +export type PublisherClientCtor = typeof PublisherClient; + +/** Maximum number of publisher clients kept alive at the same time. */ +export const CACHE_MAX_SIZE = 10; + +/** Time after which a cached publisher client is rebuilt. */ +export const CACHE_TTL_MS = 30 * 60 * 1000; + +/** + * Error reported when the optional Eventarc publishing SDK cannot be loaded. + * + * `message_tool` turns this into the model-facing `ERROR` result, mirroring the + * `ImportError` guard of the Python reference. + */ +export const EVENTARC_SDK_MISSING_ERROR = + '@google-cloud/eventarc-publishing is not installed'; + +/** + * Reported to the API as `x-goog-api-client`. Matches the Python reference's + * `adk-eventarc-tool google-adk/` user agent. + */ +const USER_AGENT_LIB_NAME = 'adk-eventarc-tool google-adk'; + +/** Identity used when no auth client is supplied. */ +const DEFAULT_CREDENTIAL_ID = 'default'; + +/** Options selecting which cached publisher client to operate on. */ +export interface PublisherClientOptions { + credentialsConfig?: EventarcCredentialsConfig; + projectId?: string; +} + +interface CacheEntry { + client: PublisherClient; + expiresAt: number; +} + +/** + * Insertion-ordered LRU cache. A `Map` preserves insertion order, so deleting + * and re-inserting a key moves it to the most-recently-used end and + * `keys().next()` yields the least-recently-used one. + */ +const publisherClientCache = new Map(); + +const anonymousCredentialIds = new WeakMap(); +let anonymousCredentialCount = 0; + +/** + * Loads the optional `@google-cloud/eventarc-publishing` SDK. + * + * @throws An error carrying {@link EVENTARC_SDK_MISSING_ERROR} when the package + * is not installed. + */ +export async function loadPublisherClientCtor(): Promise { + try { + const sdk = await import('@google-cloud/eventarc-publishing'); + return sdk.PublisherClient; + } catch { + throw new Error(EVENTARC_SDK_MISSING_ERROR); + } +} + +/** + * Returns a cached publisher client, constructing one when the cache misses or + * the cached entry has expired. + * + * Clients dropped by expiry or LRU eviction are closed before returning. + */ +export async function getPublisherClient( + options: PublisherClientOptions, +): Promise { + const publisherClientCtor = await loadPublisherClientCtor(); + const cacheKey = buildCacheKey(options); + const now = Date.now(); + const staleClients: PublisherClient[] = []; + + const cached = publisherClientCache.get(cacheKey); + if (cached) { + publisherClientCache.delete(cacheKey); + if (cached.expiresAt > now) { + publisherClientCache.set(cacheKey, cached); + return cached.client; + } + staleClients.push(cached.client); + } + + const client = new publisherClientCtor({ + authClient: options.credentialsConfig?.authClient, + scopes: resolveScopes(options.credentialsConfig), + projectId: options.projectId, + libName: USER_AGENT_LIB_NAME, + libVersion: version, + }); + + if (publisherClientCache.size >= CACHE_MAX_SIZE) { + const evictedKey = publisherClientCache.keys().next().value; + if (evictedKey !== undefined) { + const evicted = publisherClientCache.get(evictedKey); + publisherClientCache.delete(evictedKey); + if (evicted) { + staleClients.push(evicted.client); + } + } + } + + publisherClientCache.set(cacheKey, {client, expiresAt: now + CACHE_TTL_MS}); + + await Promise.all(staleClients.map(closeClient)); + return client; +} + +/** + * Drops the cached publisher client for the given options and closes it, so + * that the next publish rebuilds the underlying channel. + */ +export async function removePublisherClient( + options: PublisherClientOptions, +): Promise { + const cacheKey = buildCacheKey(options); + const entry = publisherClientCache.get(cacheKey); + if (!entry) { + return; + } + publisherClientCache.delete(cacheKey); + await closeClient(entry.client); +} + +/** Closes and drops every cached publisher client. */ +export async function cleanupPublisherClients(): Promise { + const entries = [...publisherClientCache.values()]; + publisherClientCache.clear(); + await Promise.all(entries.map((entry) => closeClient(entry.client))); +} + +async function closeClient(client: PublisherClient): Promise { + try { + await client.close(); + } catch (error: unknown) { + logger.warn('Failed to close the Eventarc publisher client', error); + } +} + +function buildCacheKey(options: PublisherClientOptions): string { + return [ + options.projectId ?? '', + resolveScopes(options.credentialsConfig).join(','), + credentialId(options.credentialsConfig?.authClient), + ].join('|'); +} + +/** + * Derives a stable identity for an auth client so that equivalent credentials + * share a channel. + * + * Only the identities `google-auth-library` actually exposes are recognised; + * anything else falls back to a per-object identifier, which is correct but + * does not deduplicate. + */ +function credentialId(authClient?: AuthClient): string { + if (!authClient) { + return DEFAULT_CREDENTIAL_ID; + } + + const email = readStringField(authClient, 'email'); + if (email) { + return email; + } + + const targetPrincipal = readStringField(authClient, 'targetPrincipal'); + if (targetPrincipal) { + return `Impersonated:${targetPrincipal}`; + } + + const audience = readStringField(authClient, 'audience'); + if (audience) { + return `ExternalAccount:${audience}`; + } + + const refreshToken = readStringField(authClient.credentials, 'refresh_token'); + if (refreshToken) { + const digest = createHash('sha256').update(refreshToken).digest('hex'); + return `UserCredentials:${digest}`; + } + + return anonymousCredentialId(authClient); +} + +function readStringField(source: object, field: string): string | undefined { + const value: unknown = Reflect.get(source, field); + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function anonymousCredentialId(authClient: AuthClient): string { + const existing = anonymousCredentialIds.get(authClient); + if (existing !== undefined) { + return existing; + } + anonymousCredentialCount += 1; + const id = `AuthClient:${anonymousCredentialCount}`; + anonymousCredentialIds.set(authClient, id); + return id; +} diff --git a/core/src/integrations/eventarc/config.ts b/core/src/integrations/eventarc/config.ts new file mode 100644 index 000000000..40cc8842b --- /dev/null +++ b/core/src/integrations/eventarc/config.ts @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AuthClient} from 'google-auth-library'; + +/** + * Default publish timeout in milliseconds. + * + * Matches the 15 second default of the Python `EventarcToolConfig`. + */ +export const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000; + +/** OAuth scope requested when the credentials config does not declare any. */ +export const CLOUD_PLATFORM_SCOPE = + 'https://www.googleapis.com/auth/cloud-platform'; + +/** + * CloudEvents requires extension attribute names to consist of lower-case + * letters and digits only. + */ +export const CUSTOM_ATTRIBUTE_KEY_PATTERN = /^[a-z0-9]+$/; + +/** Configuration for the Eventarc tools. */ +export interface EventarcToolConfig { + /** Project ID used for telemetry and API calls. */ + projectId?: string; + + /** + * Timeout in milliseconds for publishing a message. Defaults to + * {@link DEFAULT_PUBLISH_TIMEOUT_MS}. + */ + publishTimeoutMs?: number; +} + +/** Configuration for the Google Cloud credentials used to publish. */ +export interface EventarcCredentialsConfig { + /** + * Pre-constructed auth client. When omitted, Application Default Credentials + * are resolved by the publisher client. + */ + authClient?: AuthClient; + + /** OAuth scopes. Defaults to `[CLOUD_PLATFORM_SCOPE]`. */ + scopes?: string[]; +} + +/** Returns the publish timeout in milliseconds, applying the default. */ +export function resolvePublishTimeoutMs(config?: EventarcToolConfig): number { + return config?.publishTimeoutMs ?? DEFAULT_PUBLISH_TIMEOUT_MS; +} + +/** Returns the OAuth scopes to request, applying the default. */ +export function resolveScopes(config?: EventarcCredentialsConfig): string[] { + return config?.scopes ?? [CLOUD_PLATFORM_SCOPE]; +} diff --git a/core/src/integrations/eventarc/eventarc_toolset.ts b/core/src/integrations/eventarc/eventarc_toolset.ts new file mode 100644 index 000000000..05916acd1 --- /dev/null +++ b/core/src/integrations/eventarc/eventarc_toolset.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ReadonlyContext} from '../../agents/readonly_context.js'; +import {BaseTool} from '../../tools/base_tool.js'; +import {BaseToolset, ToolPredicate} from '../../tools/base_toolset.js'; +import {experimental} from '../../utils/experimental.js'; +import {cleanupPublisherClients} from './client.js'; +import {EventarcCredentialsConfig, EventarcToolConfig} from './config.js'; +import {createPublishMessageTool} from './message_tool.js'; + +export { + CLOUD_PLATFORM_SCOPE, + DEFAULT_PUBLISH_TIMEOUT_MS, + type EventarcCredentialsConfig, + type EventarcToolConfig, +} from './config.js'; +export { + publishMessage, + type PublishMessageOptions, + type PublishMessageResult, +} from './message_tool.js'; + +/** Arguments accepted by the {@link EventarcToolset} constructor. */ +export interface EventarcToolsetOptions { + toolConfig?: EventarcToolConfig; + credentialsConfig?: EventarcCredentialsConfig; + toolFilter?: ToolPredicate | string[]; + prefix?: string; +} + +/** + * Toolset for publishing CloudEvents to Google Cloud Eventarc Advanced. + * + * Exposes the generic `publish_message` tool, which lets the model supply + * every CloudEvent attribute itself. + */ +@experimental +export class EventarcToolset extends BaseToolset { + readonly toolConfig: EventarcToolConfig; + readonly credentialsConfig: EventarcCredentialsConfig; + + private readonly tools: BaseTool[]; + + constructor(options: EventarcToolsetOptions = {}) { + super(options.toolFilter ?? [], options.prefix); + this.toolConfig = options.toolConfig ?? {}; + this.credentialsConfig = options.credentialsConfig ?? {}; + this.tools = [ + createPublishMessageTool({ + toolConfig: this.toolConfig, + credentialsConfig: this.credentialsConfig, + }), + ]; + } + + override async getTools(context?: ReadonlyContext): Promise { + if (!context) { + return [...this.tools]; + } + return this.tools.filter((tool) => this.isToolSelected(tool, context)); + } + + override async close(): Promise { + return cleanupPublisherClients(); + } +} diff --git a/core/src/integrations/eventarc/message_tool.ts b/core/src/integrations/eventarc/message_tool.ts new file mode 100644 index 000000000..849f13c4e --- /dev/null +++ b/core/src/integrations/eventarc/message_tool.ts @@ -0,0 +1,522 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {protos} from '@google-cloud/eventarc-publishing'; +import {Schema, Type} from '@google/genai'; +import {context, propagation} from '@opentelemetry/api'; + +import {BaseTool} from '../../tools/base_tool.js'; +import {FunctionTool} from '../../tools/function_tool.js'; +import {randomUUID} from '../../utils/env_aware_utils.js'; +import {isRecord} from '../../utils/object_utils.js'; +import { + getPublisherClient, + loadPublisherClientCtor, + removePublisherClient, +} from './client.js'; +import { + CUSTOM_ATTRIBUTE_KEY_PATTERN, + EventarcCredentialsConfig, + EventarcToolConfig, + resolvePublishTimeoutMs, +} from './config.js'; + +type CloudEventMessage = protos.google.cloud.eventarc.publishing.v1.ICloudEvent; +type CloudEventAttributeValue = + protos.google.cloud.eventarc.publishing.v1.CloudEvent.ICloudEventAttributeValue; + +/** Name of the generic publish tool exposed by {@link EventarcToolset}. */ +export const PUBLISH_MESSAGE_TOOL_NAME = 'publish_message'; + +/** CloudEvents specification version used when the caller does not set one. */ +const DEFAULT_SPEC_VERSION = '1.0'; + +const CONTENT_TYPE_JSON = 'application/json'; +const CONTENT_TYPE_TEXT = 'text/plain'; +const CONTENT_TYPE_OCTET_STREAM = 'application/octet-stream'; + +/** Canonical base64 alphabet with at most two padding characters. */ +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/; + +const RFC_3339_PATTERN = + /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/; + +const ERROR_INVALID_TYPE = 'type must be a non-empty string'; +const ERROR_INVALID_SOURCE = 'source must be a non-empty string'; +const ERROR_INVALID_ID = 'id, if provided, must be a non-empty string'; +const ERROR_INVALID_TIME = 'time must be a string'; +const ERROR_INVALID_CUSTOM_ATTRIBUTES = 'custom_attributes must be an object'; +const ERROR_INVALID_BASE64_DATA = + 'data must be a string when is_base64_encoded is true'; + +/** Arguments accepted by {@link publishMessage}. */ +export interface PublishMessageOptions { + /** + * Fully-qualified message bus resource name, in the form + * `projects/{project}/locations/{location}/messageBuses/{messageBus}`. + */ + bus: string; + + /** CloudEvents `type` attribute, e.g. `com.example.object.created`. */ + type: string; + + /** CloudEvents `source` attribute; a URI-reference identifying the producer. */ + source: string; + + /** Tool configuration supplying the project ID and publish timeout. */ + toolConfig?: EventarcToolConfig; + + /** Credentials used to build the publisher client. */ + credentialsConfig?: EventarcCredentialsConfig; + + /** Event payload. Omit to publish an event without data. */ + data?: unknown; + + /** Set when `data` is a base64-encoded string that must be published as bytes. */ + isBase64Encoded?: boolean; + + /** Set to copy the active W3C trace context into the event attributes. */ + includeTracingExtension?: boolean; + + /** MIME type of the payload. Inferred from `data` when omitted. */ + datacontenttype?: string; + + /** CloudEvents `specversion`. Defaults to `1.0`. */ + specversion?: string; + + /** CloudEvents `subject` attribute. */ + subject?: string; + + /** CloudEvents `id`. A UUID is generated when omitted. */ + id?: string; + + /** + * RFC 3339 timestamp. The current time is used when omitted; pass an empty + * string to drop the attribute entirely. + */ + time?: string; + + /** Extension attributes. Keys must be lower-case alphanumeric. */ + customAttributes?: Record; +} + +/** + * Outcome of a publish attempt. + * + * The keys stay snake_case and the status strings stay upper-case because the + * model reads them, matching the Python reference. + */ +export type PublishMessageResult = + | {status: 'SUCCESS'; message_id: string} + | {status: 'ERROR'; error_details: string}; + +/** + * Publishes a CloudEvent to an Eventarc Advanced message bus. + * + * Never throws: invalid input and transport failures are reported as an + * `ERROR` result so that the model can react to them. + */ +export async function publishMessage( + options: PublishMessageOptions, +): Promise { + try { + await loadPublisherClientCtor(); + } catch (error: unknown) { + return errorResult(error); + } + + let prepared: PreparedCloudEvent; + try { + prepared = buildCloudEvent(options); + } catch (error: unknown) { + return errorResult(error); + } + + const clientOptions = { + credentialsConfig: options.credentialsConfig, + projectId: options.toolConfig?.projectId, + }; + + try { + const client = await getPublisherClient(clientOptions); + await client.publish( + {messageBus: options.bus, protoMessage: prepared.message}, + {timeout: resolvePublishTimeoutMs(options.toolConfig)}, + ); + return {status: 'SUCCESS', message_id: prepared.id}; + } catch (error: unknown) { + await removePublisherClient(clientOptions); + return errorResult(error); + } +} + +/** Model-facing declaration of the generic `publish_message` tool. */ +const PUBLISH_MESSAGE_PARAMETERS: Schema = { + type: Type.OBJECT, + properties: { + bus: { + type: Type.STRING, + description: + 'Fully-qualified Eventarc Advanced message bus resource name, in the ' + + 'form projects/{project}/locations/{location}/messageBuses/{bus}.', + }, + type: { + type: Type.STRING, + description: + 'CloudEvents type describing the occurrence, e.g. ' + + 'com.example.object.created.', + }, + source: { + type: Type.STRING, + description: + 'CloudEvents source; a URI-reference identifying the context in which ' + + 'the event happened.', + }, + data: { + type: Type.STRING, + description: + 'Event payload. Send structured payloads as a JSON-encoded string. To ' + + 'send binary data, base64-encode it and set is_base64_encoded to true.', + }, + is_base64_encoded: { + type: Type.BOOLEAN, + description: + 'Set to true only when data is a base64-encoded string that should be ' + + 'published as raw bytes.', + }, + include_tracing_extension: { + type: Type.BOOLEAN, + description: + 'Set to true to copy the active distributed tracing context into the ' + + 'event attributes.', + }, + datacontenttype: { + type: Type.STRING, + description: + 'MIME type of the payload. Inferred from data when omitted; pass an ' + + 'empty string to drop the attribute.', + }, + specversion: { + type: Type.STRING, + description: `CloudEvents specification version. Defaults to ${DEFAULT_SPEC_VERSION}.`, + }, + subject: { + type: Type.STRING, + description: 'Subject of the event in the context of the producer.', + }, + id: { + type: Type.STRING, + description: 'Unique event id. A UUID is generated when omitted.', + }, + time: { + type: Type.STRING, + description: + 'RFC 3339 timestamp of the event. The current time is used when ' + + 'omitted; pass an empty string to drop the attribute.', + }, + custom_attributes: { + type: Type.OBJECT, + description: + 'Extension attributes. Keys must be lower-case alphanumeric and must ' + + 'not shadow a standard CloudEvent attribute.', + }, + }, + required: ['bus', 'type', 'source'], +}; + +/** + * Builds the generic `publish_message` tool, which lets the model supply every + * CloudEvent attribute itself. + */ +export function createPublishMessageTool(options: { + toolConfig?: EventarcToolConfig; + credentialsConfig?: EventarcCredentialsConfig; +}): BaseTool { + return new FunctionTool({ + name: PUBLISH_MESSAGE_TOOL_NAME, + description: + 'Publishes a structured CloudEvent to a Google Cloud Eventarc Advanced ' + + 'message bus so that downstream subscribers receive it.', + parameters: PUBLISH_MESSAGE_PARAMETERS, + async execute(input: unknown): Promise { + let publishOptions: PublishMessageOptions; + try { + publishOptions = toPublishMessageOptions(asRecord(input), options); + } catch (error: unknown) { + return errorResult(error); + } + return publishMessage(publishOptions); + }, + }); +} + +interface PreparedCloudEvent { + /** Assembled CloudEvent proto message. */ + message: CloudEventMessage; + /** Event id echoed back to the model as `message_id`. */ + id: string; +} + +interface SerializedData { + datacontenttype?: string; + textData?: string; + binaryData?: Uint8Array; +} + +function buildCloudEvent(options: PublishMessageOptions): PreparedCloudEvent { + requireNonEmptyString(options.type, ERROR_INVALID_TYPE); + requireNonEmptyString(options.source, ERROR_INVALID_SOURCE); + if (options.id !== undefined) { + requireNonEmptyString(options.id, ERROR_INVALID_ID); + } + + const data = options.isBase64Encoded + ? decodeBase64(options.data) + : options.data; + const attributes = buildCustomAttributes(options.customAttributes); + const time = resolveEventTime(options.time); + const serialized = serializeData(data, options.datacontenttype); + + if (options.includeTracingExtension) { + Object.assign(attributes, activeTraceAttributes()); + } + if (serialized.datacontenttype) { + attributes['datacontenttype'] = serialized.datacontenttype; + } + if (time) { + attributes['time'] = time; + } + if (options.subject) { + attributes['subject'] = options.subject; + } + + const id = options.id ?? randomUUID(); + const message: CloudEventMessage = { + id, + source: options.source, + type: options.type, + specVersion: options.specversion ?? DEFAULT_SPEC_VERSION, + attributes: toAttributeValues(attributes), + }; + if (serialized.textData !== undefined) { + message.textData = serialized.textData; + } + if (serialized.binaryData !== undefined) { + message.binaryData = serialized.binaryData; + } + + return {message, id}; +} + +function requireNonEmptyString(value: unknown, message: string): void { + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(message); + } +} + +/** + * Decodes a base64 payload. + * + * `Buffer.from(value, 'base64')` silently discards characters outside the + * base64 alphabet, so the input is validated before it is decoded. + */ +function decodeBase64(data: unknown): Uint8Array { + if (typeof data !== 'string') { + throw new Error(ERROR_INVALID_BASE64_DATA); + } + const compact = data.replace(/\s/g, ''); + if (!BASE64_PATTERN.test(compact) || compact.length % 4 !== 0) { + throw new Error('Invalid base64 string: the value is not valid base64'); + } + return Buffer.from(compact, 'base64'); +} + +function buildCustomAttributes( + customAttributes: Record | undefined, +): Record { + const attributes: Record = {}; + if (!customAttributes) { + return attributes; + } + for (const [key, value] of Object.entries(customAttributes)) { + if (!CUSTOM_ATTRIBUTE_KEY_PATTERN.test(key)) { + throw new Error( + `Invalid custom attribute key: ${key}. Keys must be lowercase alphanumeric.`, + ); + } + attributes[key] = String(value); + } + return attributes; +} + +function resolveEventTime(time: string | undefined): string | undefined { + if (time === undefined) { + return new Date().toISOString(); + } + if (time === '') { + return undefined; + } + if (!RFC_3339_PATTERN.test(time) || Number.isNaN(Date.parse(time))) { + throw new Error(`Invalid RFC 3339 time format: ${time}`); + } + return time; +} + +function serializeData( + data: unknown, + declaredContentType: string | undefined, +): SerializedData { + if (data === undefined || data === null || data === '') { + return {datacontenttype: declaredContentType}; + } + + const datacontenttype = declaredContentType ?? inferContentType(data); + const failureMessage = + datacontenttype === CONTENT_TYPE_JSON + ? 'Failed to serialize data to JSON' + : 'Failed to serialize data'; + + if (isBinaryData(data)) { + return {datacontenttype, binaryData: data}; + } + if (typeof data === 'string') { + return {datacontenttype, textData: data}; + } + if (datacontenttype === CONTENT_TYPE_JSON || typeof data === 'object') { + return {datacontenttype, textData: stringify(data, failureMessage)}; + } + return {datacontenttype, textData: String(data)}; +} + +function inferContentType(data: unknown): string { + if (isBinaryData(data)) { + return CONTENT_TYPE_OCTET_STREAM; + } + if (typeof data === 'string') { + return CONTENT_TYPE_TEXT; + } + return CONTENT_TYPE_JSON; +} + +/** + * Recognises `Uint8Array` and `Buffer` without `instanceof`, so values created + * in another realm are still detected. + */ +function isBinaryData(value: unknown): value is Uint8Array { + return Object.prototype.toString.call(value) === '[object Uint8Array]'; +} + +function stringify(value: unknown, failureMessage: string): string { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch (error: unknown) { + throw new Error(`${failureMessage}: ${toErrorMessage(error)}`); + } + if (serialized === undefined) { + throw new Error(`${failureMessage}: value is not JSON-serializable`); + } + return serialized; +} + +function activeTraceAttributes(): Record { + const carrier: Record = {}; + propagation.inject(context.active(), carrier); + const attributes: Record = {}; + for (const key of ['traceparent', 'tracestate']) { + const value = carrier[key]; + if (value) { + attributes[key] = value; + } + } + return attributes; +} + +function toAttributeValues( + attributes: Record, +): Record { + const values: Record = {}; + for (const [key, value] of Object.entries(attributes)) { + values[key] = {ceString: value}; + } + return values; +} + +function toPublishMessageOptions( + args: Record, + defaults: { + toolConfig?: EventarcToolConfig; + credentialsConfig?: EventarcCredentialsConfig; + }, +): PublishMessageOptions { + return { + ...defaults, + bus: asString(args['bus']) ?? '', + type: requireStringType(args['type'], ERROR_INVALID_TYPE) ?? '', + source: requireStringType(args['source'], ERROR_INVALID_SOURCE) ?? '', + data: args['data'], + isBase64Encoded: args['is_base64_encoded'] === true, + includeTracingExtension: args['include_tracing_extension'] === true, + datacontenttype: asString(args['datacontenttype']), + specversion: asString(args['specversion']), + subject: asString(args['subject']), + id: requireStringType(args['id'], ERROR_INVALID_ID), + time: requireStringType(args['time'], ERROR_INVALID_TIME), + customAttributes: requireRecordType( + args['custom_attributes'], + ERROR_INVALID_CUSTOM_ATTRIBUTES, + ), + }; +} + +/** Returns the value when it is a string, ignoring any other type. */ +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** Returns a present value only when it is a string, otherwise throws. */ +function requireStringType( + value: unknown, + message: string, +): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (typeof value !== 'string') { + throw new Error(message); + } + return value; +} + +function requireRecordType( + value: unknown, + message: string, +): Record | undefined { + if (value === undefined || value === null) { + return undefined; + } + if (!isRecord(value)) { + throw new Error(message); + } + return value; +} + +/** + * `FunctionTool` types the callback argument as `unknown` but always passes + * its `Record` argument bag, so the fallback only defends + * untyped JavaScript callers that invoke the callback directly. + */ +function asRecord(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function errorResult(error: unknown): PublishMessageResult { + return {status: 'ERROR', error_details: toErrorMessage(error)}; +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/core/src/utils/object_utils.ts b/core/src/utils/object_utils.ts new file mode 100644 index 000000000..f7094e4ed --- /dev/null +++ b/core/src/utils/object_utils.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Narrows an unknown value to a plain, string-keyed object. + * + * Arrays are rejected: they are objects at runtime but are never a valid + * key/value bag. + */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/core/test/integrations/eventarc/client_sdk_unavailable_test.ts b/core/test/integrations/eventarc/client_sdk_unavailable_test.ts new file mode 100644 index 000000000..a66176b18 --- /dev/null +++ b/core/test/integrations/eventarc/client_sdk_unavailable_test.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it, vi} from 'vitest'; + +import { + EVENTARC_SDK_MISSING_ERROR, + getPublisherClient, + loadPublisherClientCtor, +} from '../../../src/integrations/eventarc/client.js'; + +/** + * Reproduces an installation without the optional + * `@google-cloud/eventarc-publishing` peer dependency. + * + * The failure lives in its own file because a module mock that fails to load + * cannot be undone for later tests in the same module registry. + */ +vi.mock('@google-cloud/eventarc-publishing', () => { + throw new Error("Cannot find package '@google-cloud/eventarc-publishing'"); +}); + +describe('the optional Eventarc SDK is not installed', () => { + it('reports a clear error from loadPublisherClientCtor', async () => { + await expect(loadPublisherClientCtor()).rejects.toThrow( + EVENTARC_SDK_MISSING_ERROR, + ); + }); + + it('reports the same error from getPublisherClient', async () => { + await expect(getPublisherClient({projectId: 'my-project'})).rejects.toThrow( + EVENTARC_SDK_MISSING_ERROR, + ); + }); +}); diff --git a/core/test/integrations/eventarc/client_test.ts b/core/test/integrations/eventarc/client_test.ts new file mode 100644 index 000000000..cd028069f --- /dev/null +++ b/core/test/integrations/eventarc/client_test.ts @@ -0,0 +1,270 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ExternalAccountClient, + Impersonated, + JWT, + OAuth2Client, +} from 'google-auth-library'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + CACHE_MAX_SIZE, + CACHE_TTL_MS, + cleanupPublisherClients, + getPublisherClient, + loadPublisherClientCtor, + removePublisherClient, +} from '../../../src/integrations/eventarc/client.js'; +import {CLOUD_PLATFORM_SCOPE} from '../../../src/integrations/eventarc/config.js'; +import {logger} from '../../../src/utils/logger.js'; + +interface FakePublisherClientOptions { + authClient?: unknown; + scopes?: string[]; + projectId?: string; + libName?: string; + libVersion?: string; +} + +const mocks = vi.hoisted(() => ({ + constructed: [] as FakePublisherClientOptions[], + closed: [] as FakePublisherClientOptions[], + closeError: {value: undefined as Error | undefined}, +})); + +vi.mock('@google-cloud/eventarc-publishing', () => { + class FakePublisherClient { + constructor(readonly options: FakePublisherClientOptions) { + mocks.constructed.push(options); + } + async close(): Promise { + mocks.closed.push(this.options); + if (mocks.closeError.value) { + throw mocks.closeError.value; + } + } + } + return {PublisherClient: FakePublisherClient}; +}); + +function externalAccountClient() { + const client = ExternalAccountClient.fromJSON({ + type: 'external_account', + audience: '//iam.googleapis.com/projects/1/locations/global/pool/provider', + subject_token_type: 'urn:ietf:params:oauth:token-type:jwt', + token_url: 'https://sts.googleapis.com/v1/token', + credential_source: {file: '/dev/null'}, + }); + if (!client) { + expect.fail('failed to build an external account client'); + } + return client; +} + +function userRefreshClient(refreshToken: string) { + const client = new OAuth2Client(); + client.setCredentials({refresh_token: refreshToken}); + return client; +} + +beforeEach(() => { + mocks.constructed.length = 0; + mocks.closed.length = 0; + mocks.closeError.value = undefined; +}); + +afterEach(async () => { + await cleanupPublisherClients(); + vi.useRealTimers(); +}); + +describe('loadPublisherClientCtor', () => { + it('resolves the publisher client constructor', async () => { + await expect(loadPublisherClientCtor()).resolves.toBeTypeOf('function'); + }); +}); + +describe('getPublisherClient caching', () => { + it('builds the client with the default scope and the ADK user agent', async () => { + await getPublisherClient({projectId: 'my-project'}); + + expect(mocks.constructed).toHaveLength(1); + expect(mocks.constructed[0]).toMatchObject({ + projectId: 'my-project', + scopes: [CLOUD_PLATFORM_SCOPE], + libName: 'adk-eventarc-tool google-adk', + }); + }); + + it('reuses the cached client for the same key', async () => { + const first = await getPublisherClient({projectId: 'my-project'}); + const second = await getPublisherClient({projectId: 'my-project'}); + + expect(second).toBe(first); + expect(mocks.constructed).toHaveLength(1); + }); + + it('builds a separate client per project', async () => { + const first = await getPublisherClient({projectId: 'project-a'}); + const second = await getPublisherClient({projectId: 'project-b'}); + + expect(second).not.toBe(first); + expect(mocks.constructed).toHaveLength(2); + }); + + it('builds a separate client per scope set', async () => { + const first = await getPublisherClient({ + credentialsConfig: {scopes: ['https://example.test/a']}, + }); + const second = await getPublisherClient({ + credentialsConfig: {scopes: ['https://example.test/b']}, + }); + + expect(second).not.toBe(first); + expect(mocks.constructed).toHaveLength(2); + }); + + it('shares a client between auth clients with the same identity', async () => { + const first = await getPublisherClient({ + credentialsConfig: {authClient: new JWT({email: 'sa@example.test'})}, + }); + const second = await getPublisherClient({ + credentialsConfig: {authClient: new JWT({email: 'sa@example.test'})}, + }); + + expect(second).toBe(first); + expect(mocks.constructed).toHaveLength(1); + }); + + it('separates clients for different service accounts', async () => { + await getPublisherClient({ + credentialsConfig: {authClient: new JWT({email: 'a@example.test'})}, + }); + await getPublisherClient({ + credentialsConfig: {authClient: new JWT({email: 'b@example.test'})}, + }); + + expect(mocks.constructed).toHaveLength(2); + }); + + it('separates clients for impersonated, external and user credentials', async () => { + const impersonated = new Impersonated({ + sourceClient: new OAuth2Client(), + targetPrincipal: 'target@example.test', + targetScopes: [CLOUD_PLATFORM_SCOPE], + }); + + await getPublisherClient({credentialsConfig: {authClient: impersonated}}); + await getPublisherClient({ + credentialsConfig: {authClient: externalAccountClient()}, + }); + await getPublisherClient({ + credentialsConfig: {authClient: userRefreshClient('refresh-1')}, + }); + await getPublisherClient({ + credentialsConfig: {authClient: userRefreshClient('refresh-1')}, + }); + await getPublisherClient({ + credentialsConfig: {authClient: userRefreshClient('refresh-2')}, + }); + + expect(mocks.constructed).toHaveLength(4); + }); + + it('keeps unidentifiable auth clients apart by object identity', async () => { + const anonymous = new OAuth2Client(); + + const first = await getPublisherClient({ + credentialsConfig: {authClient: anonymous}, + }); + const second = await getPublisherClient({ + credentialsConfig: {authClient: anonymous}, + }); + const third = await getPublisherClient({ + credentialsConfig: {authClient: new OAuth2Client()}, + }); + + expect(second).toBe(first); + expect(third).not.toBe(first); + expect(mocks.constructed).toHaveLength(2); + }); + + it('rebuilds the client once the TTL has elapsed', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-06-03T12:00:00Z')); + + const first = await getPublisherClient({projectId: 'my-project'}); + vi.setSystemTime(Date.now() + CACHE_TTL_MS + 1); + const second = await getPublisherClient({projectId: 'my-project'}); + + expect(second).not.toBe(first); + expect(mocks.constructed).toHaveLength(2); + expect(mocks.closed).toHaveLength(1); + }); + + it('evicts and closes the least recently used client when full', async () => { + for (let index = 0; index < CACHE_MAX_SIZE; index++) { + await getPublisherClient({projectId: `project-${index}`}); + } + // Touch the oldest entry so that project-1 becomes the eviction target. + const touched = await getPublisherClient({projectId: 'project-0'}); + + await getPublisherClient({projectId: 'overflow'}); + + expect(mocks.closed).toHaveLength(1); + expect(mocks.closed[0]).toMatchObject({projectId: 'project-1'}); + expect(await getPublisherClient({projectId: 'project-0'})).toBe(touched); + expect(mocks.constructed).toHaveLength(CACHE_MAX_SIZE + 1); + }); +}); + +describe('removePublisherClient', () => { + it('closes and drops the cached client', async () => { + const first = await getPublisherClient({projectId: 'my-project'}); + + await removePublisherClient({projectId: 'my-project'}); + + expect(mocks.closed).toHaveLength(1); + expect(mocks.closed[0]).toMatchObject({projectId: 'my-project'}); + expect(await getPublisherClient({projectId: 'my-project'})).not.toBe(first); + }); + + it('is a no-op for an unknown key', async () => { + await removePublisherClient({projectId: 'never-cached'}); + + expect(mocks.closed).toHaveLength(0); + }); + + it('logs and continues when closing fails', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + await getPublisherClient({projectId: 'my-project'}); + mocks.closeError.value = new Error('close failed'); + + await expect( + removePublisherClient({projectId: 'my-project'}), + ).resolves.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + 'Failed to close the Eventarc publisher client', + expect.objectContaining({message: 'close failed'}), + ); + warnSpy.mockRestore(); + }); +}); + +describe('cleanupPublisherClients', () => { + it('closes every cached client', async () => { + await getPublisherClient({projectId: 'project-a'}); + await getPublisherClient({projectId: 'project-b'}); + + await cleanupPublisherClients(); + + expect(mocks.closed).toHaveLength(2); + await getPublisherClient({projectId: 'project-a'}); + expect(mocks.constructed).toHaveLength(3); + }); +}); diff --git a/core/test/integrations/eventarc/config_test.ts b/core/test/integrations/eventarc/config_test.ts new file mode 100644 index 000000000..136558498 --- /dev/null +++ b/core/test/integrations/eventarc/config_test.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; + +import { + CLOUD_PLATFORM_SCOPE, + DEFAULT_PUBLISH_TIMEOUT_MS, + EventarcToolConfig, + resolvePublishTimeoutMs, + resolveScopes, +} from '../../../src/integrations/eventarc/config.js'; + +describe('resolvePublishTimeoutMs', () => { + it('falls back to the default when no config is supplied', () => { + expect(resolvePublishTimeoutMs()).toBe(DEFAULT_PUBLISH_TIMEOUT_MS); + }); + + it('falls back to the default when the timeout is unset', () => { + const config: EventarcToolConfig = {projectId: 'my-project'}; + expect(resolvePublishTimeoutMs(config)).toBe(DEFAULT_PUBLISH_TIMEOUT_MS); + expect(config.projectId).toBe('my-project'); + }); + + it('keeps an explicit timeout', () => { + expect(resolvePublishTimeoutMs({publishTimeoutMs: 30_000})).toBe(30_000); + }); + + it('keeps a zero timeout instead of treating it as unset', () => { + expect(resolvePublishTimeoutMs({publishTimeoutMs: 0})).toBe(0); + }); + + it('matches the 15 second default of the Python toolset', () => { + expect(DEFAULT_PUBLISH_TIMEOUT_MS).toBe(15_000); + }); +}); + +describe('resolveScopes', () => { + it('falls back to the cloud-platform scope when no config is supplied', () => { + expect(resolveScopes()).toEqual([CLOUD_PLATFORM_SCOPE]); + }); + + it('falls back to the cloud-platform scope when scopes are unset', () => { + expect(resolveScopes({})).toEqual([CLOUD_PLATFORM_SCOPE]); + }); + + it('keeps explicit scopes', () => { + const scopes = ['https://www.googleapis.com/auth/eventarc']; + expect(resolveScopes({scopes})).toEqual(scopes); + }); +}); diff --git a/core/test/integrations/eventarc/eventarc_test_utils.ts b/core/test/integrations/eventarc/eventarc_test_utils.ts new file mode 100644 index 000000000..78fbdd971 --- /dev/null +++ b/core/test/integrations/eventarc/eventarc_test_utils.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Context} from '../../../src/agents/context.js'; +import {InvocationContext} from '../../../src/agents/invocation_context.js'; +import {LlmAgent} from '../../../src/agents/llm_agent.js'; +import {PluginManager} from '../../../src/plugins/plugin_manager.js'; +import {createSession} from '../../../src/sessions/session.js'; + +/** + * Builds a real tool context for the Eventarc tools. + * + * The tools never read the context, but `runAsync` requires one, and building + * it from real collaborators keeps the tests free of casts. The imports are + * relative so that the context type matches the modules under test, which are + * also imported from source. + */ +export function createToolContext(): Context { + return new Context({ + invocationContext: new InvocationContext({ + invocationId: 'test-invocation', + agent: new LlmAgent({name: 'test_agent', model: 'gemini-2.5-flash'}), + session: createSession({ + id: 'test-session', + appName: 'test-app', + userId: 'test-user', + }), + pluginManager: new PluginManager([]), + }), + }); +} diff --git a/core/test/integrations/eventarc/eventarc_toolset_test.ts b/core/test/integrations/eventarc/eventarc_toolset_test.ts new file mode 100644 index 000000000..d7b3ad4d3 --- /dev/null +++ b/core/test/integrations/eventarc/eventarc_toolset_test.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +import {ReadonlyContext} from '../../../src/agents/readonly_context.js'; +import {cleanupPublisherClients} from '../../../src/integrations/eventarc/client.js'; +import { + DEFAULT_PUBLISH_TIMEOUT_MS, + resolvePublishTimeoutMs, +} from '../../../src/integrations/eventarc/config.js'; +import {EventarcToolset} from '../../../src/integrations/eventarc/eventarc_toolset.js'; +import {BaseTool} from '../../../src/tools/base_tool.js'; +import {logger} from '../../../src/utils/logger.js'; +import {createToolContext} from './eventarc_test_utils.js'; + +vi.mock('../../../src/integrations/eventarc/client.js', () => ({ + getPublisherClient: vi.fn(async () => ({ + publish: vi.fn(), + close: vi.fn(), + })), + removePublisherClient: vi.fn(async () => {}), + cleanupPublisherClients: vi.fn(async () => {}), + loadPublisherClientCtor: vi.fn(async () => class {}), +})); + +const CONTEXT: ReadonlyContext = createToolContext(); + +function toolNames(tools: BaseTool[]): string[] { + return tools.map((tool) => tool.name); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('EventarcToolset', () => { + // The experimental decorator warns once per class, so this must be the first + // test in the file to observe the warning. + it('warns that the toolset is experimental on first construction', () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + + new EventarcToolset(); + + expect(warnSpy).toHaveBeenCalledWith( + 'Class EventarcToolset is experimental and may change in the future.', + ); + warnSpy.mockRestore(); + }); + + it('applies empty configuration by default', () => { + const toolset = new EventarcToolset(); + + expect(toolset.toolConfig).toEqual({}); + expect(toolset.credentialsConfig).toEqual({}); + expect(resolvePublishTimeoutMs(toolset.toolConfig)).toBe( + DEFAULT_PUBLISH_TIMEOUT_MS, + ); + }); + + it('retains explicit configuration', () => { + const toolConfig = {projectId: 'test-project', publishTimeoutMs: 5_000}; + const credentialsConfig = {scopes: ['https://example.test/scope']}; + + const toolset = new EventarcToolset({toolConfig, credentialsConfig}); + + expect(toolset.toolConfig).toBe(toolConfig); + expect(toolset.credentialsConfig).toBe(credentialsConfig); + }); + + it('exposes the prefix and tool filter to the base toolset', () => { + const toolset = new EventarcToolset({ + prefix: 'orders', + toolFilter: ['publish_message'], + }); + + expect(toolset.prefix).toBe('orders'); + expect(toolset.toolFilter).toEqual(['publish_message']); + }); + + it('returns only the generic publish tool by default', async () => { + const toolset = new EventarcToolset(); + + expect(toolNames(await toolset.getTools())).toEqual(['publish_message']); + }); + + it('filters the generic tool out when the filter excludes it', async () => { + const toolset = new EventarcToolset({toolFilter: ['other_tool']}); + + expect(toolNames(await toolset.getTools(CONTEXT))).toEqual([]); + expect(toolNames(await toolset.getTools())).toEqual(['publish_message']); + }); + + it('keeps the generic tool when a predicate selects it', async () => { + const toolset = new EventarcToolset({ + toolFilter: (tool) => tool.name === 'publish_message', + }); + + expect(toolNames(await toolset.getTools(CONTEXT))).toEqual([ + 'publish_message', + ]); + }); + + it('closes every cached publisher client', async () => { + const toolset = new EventarcToolset(); + + await toolset.close(); + + expect(vi.mocked(cleanupPublisherClients)).toHaveBeenCalledOnce(); + }); +}); diff --git a/core/test/integrations/eventarc/message_tool_test.ts b/core/test/integrations/eventarc/message_tool_test.ts new file mode 100644 index 000000000..ae1132904 --- /dev/null +++ b/core/test/integrations/eventarc/message_tool_test.ts @@ -0,0 +1,777 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {protos} from '@google-cloud/eventarc-publishing'; +import {Schema, Type} from '@google/genai'; +import {propagation, TextMapPropagator} from '@opentelemetry/api'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +import { + getPublisherClient, + removePublisherClient, +} from '../../../src/integrations/eventarc/client.js'; +import { + createPublishMessageTool, + publishMessage, + PublishMessageOptions, + PublishMessageResult, +} from '../../../src/integrations/eventarc/message_tool.js'; +import {createToolContext} from './eventarc_test_utils.js'; + +type CloudEventMessage = protos.google.cloud.eventarc.publishing.v1.ICloudEvent; +type PublishRequest = + protos.google.cloud.eventarc.publishing.v1.IPublishRequest; + +const mocks = vi.hoisted(() => ({ + publish: + vi.fn< + (request: PublishRequest, options: {timeout?: number}) => Promise + >(), + close: vi.fn<() => Promise>(), + sdk: {available: true}, +})); + +vi.mock('../../../src/integrations/eventarc/client.js', () => ({ + getPublisherClient: vi.fn(async () => ({ + publish: mocks.publish, + close: mocks.close, + })), + removePublisherClient: vi.fn(async () => {}), + cleanupPublisherClients: vi.fn(async () => {}), + loadPublisherClientCtor: vi.fn(async () => { + if (!mocks.sdk.available) { + throw new Error('@google-cloud/eventarc-publishing is not installed'); + } + return class {}; + }), +})); + +const BASE_OPTIONS: PublishMessageOptions = { + bus: 'projects/test/locations/global/messageBuses/my-bus', + type: 'com.example.test', + source: '//test/source', +}; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +const TOOL_CONTEXT = createToolContext(); + +function publishedEvent(): CloudEventMessage { + const call = mocks.publish.mock.calls.at(-1); + if (!call) { + expect.fail('the publisher client was not called'); + } + const event = call[0].protoMessage; + if (!event) { + expect.fail('the publish request carried no CloudEvent'); + } + return event; +} + +function publishedAttributes(): Record { + const attributes: Record = {}; + for (const [key, value] of Object.entries( + publishedEvent().attributes ?? {}, + )) { + if (typeof value.ceString === 'string') { + attributes[key] = value.ceString; + } + } + return attributes; +} + +function expectSuccess(result: PublishMessageResult): string { + if (result.status !== 'SUCCESS') { + expect.fail(`expected SUCCESS but got: ${result.error_details}`); + } + return result.message_id; +} + +function expectError(result: PublishMessageResult): string { + if (result.status !== 'ERROR') { + expect.fail('expected ERROR but the publish succeeded'); + } + return result.error_details; +} + +async function runTool(args: Record): Promise { + return createPublishMessageTool({}).runAsync({ + args, + toolContext: TOOL_CONTEXT, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.sdk.available = true; + mocks.publish.mockResolvedValue(undefined); +}); + +describe('publishMessage success path', () => { + it('publishes a text payload and echoes the generated id', async () => { + const result = await publishMessage({...BASE_OPTIONS, data: 'hello'}); + + const messageId = expectSuccess(result); + expect(messageId).toMatch(UUID_V4_PATTERN); + + const request = mocks.publish.mock.calls[0][0]; + expect(request.messageBus).toBe(BASE_OPTIONS.bus); + expect(publishedEvent()).toMatchObject({ + id: messageId, + source: '//test/source', + type: 'com.example.test', + specVersion: '1.0', + textData: 'hello', + }); + expect(publishedAttributes()['datacontenttype']).toBe('text/plain'); + expect(publishedAttributes()['time']).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + ); + }); + + it('applies the default publish timeout', async () => { + await publishMessage({...BASE_OPTIONS, data: 'hello'}); + + expect(mocks.publish.mock.calls[0][1]).toEqual({timeout: 15_000}); + }); + + it('applies a configured publish timeout', async () => { + await publishMessage({ + ...BASE_OPTIONS, + data: 'hello', + toolConfig: {publishTimeoutMs: 30_000}, + }); + + expect(mocks.publish.mock.calls[0][1]).toEqual({timeout: 30_000}); + }); + + it('forwards the project id and credentials to the publisher client', async () => { + const credentialsConfig = {scopes: ['https://example.test/scope']}; + await publishMessage({ + ...BASE_OPTIONS, + toolConfig: {projectId: 'my-project'}, + credentialsConfig, + }); + + expect(vi.mocked(getPublisherClient)).toHaveBeenCalledWith({ + credentialsConfig, + projectId: 'my-project', + }); + }); + + it('echoes an explicitly supplied id', async () => { + const result = await publishMessage({...BASE_OPTIONS, id: 'explicit-id'}); + + expect(expectSuccess(result)).toBe('explicit-id'); + expect(publishedEvent().id).toBe('explicit-id'); + }); + + it('keeps an explicit specversion', async () => { + await publishMessage({...BASE_OPTIONS, specversion: '1.1'}); + + expect(publishedEvent().specVersion).toBe('1.1'); + }); + + it('puts the subject in the attributes rather than on the event', async () => { + await publishMessage({...BASE_OPTIONS, subject: 'orders/42'}); + + expect(publishedAttributes()['subject']).toBe('orders/42'); + expect(publishedEvent()).not.toHaveProperty('subject'); + }); +}); + +describe('publishMessage content-type inference', () => { + it('infers application/json for an object', async () => { + await publishMessage({...BASE_OPTIONS, data: {foo: 'bar'}}); + + expect(publishedAttributes()['datacontenttype']).toBe('application/json'); + expect(publishedEvent().textData).toBe('{"foo":"bar"}'); + }); + + it('infers application/json for an array of objects', async () => { + await publishMessage({...BASE_OPTIONS, data: [{a: 1}, {b: 2}]}); + + expect(publishedAttributes()['datacontenttype']).toBe('application/json'); + expect(publishedEvent().textData).toBe('[{"a":1},{"b":2}]'); + }); + + it('infers application/json for a number', async () => { + await publishMessage({...BASE_OPTIONS, data: 42}); + + expect(publishedAttributes()['datacontenttype']).toBe('application/json'); + expect(publishedEvent().textData).toBe('42'); + }); + + it('infers application/json for a boolean', async () => { + await publishMessage({...BASE_OPTIONS, data: true}); + + expect(publishedAttributes()['datacontenttype']).toBe('application/json'); + expect(publishedEvent().textData).toBe('true'); + }); + + it('infers text/plain for a unicode string', async () => { + await publishMessage({...BASE_OPTIONS, data: 'héllo 🌍'}); + + expect(publishedAttributes()['datacontenttype']).toBe('text/plain'); + expect(publishedEvent().textData).toBe('héllo 🌍'); + }); + + it('infers application/octet-stream for bytes', async () => { + const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); + await publishMessage({...BASE_OPTIONS, data: bytes}); + + expect(publishedAttributes()['datacontenttype']).toBe( + 'application/octet-stream', + ); + expect(publishedEvent().binaryData).toBe(bytes); + expect(publishedEvent()).not.toHaveProperty('textData'); + }); + + it('serialises a deeply nested object', async () => { + const data = {a: {b: {c: [1, {d: 'e'}]}}}; + await publishMessage({...BASE_OPTIONS, data}); + + expect(publishedEvent().textData).toBe(JSON.stringify(data)); + }); + + it('keeps bytes binary under an explicit application/json content type', async () => { + const bytes = Buffer.from('binary-json'); + await publishMessage({ + ...BASE_OPTIONS, + data: bytes, + datacontenttype: 'application/json', + }); + + expect(publishedAttributes()['datacontenttype']).toBe('application/json'); + expect(publishedEvent().binaryData).toBe(bytes); + }); + + it('JSON-encodes an object under a non-JSON content type', async () => { + await publishMessage({ + ...BASE_OPTIONS, + data: {foo: 'bar'}, + datacontenttype: 'application/xml', + }); + + expect(publishedAttributes()['datacontenttype']).toBe('application/xml'); + expect(publishedEvent().textData).toBe('{"foo":"bar"}'); + }); + + it('stringifies a primitive under a non-JSON content type', async () => { + await publishMessage({ + ...BASE_OPTIONS, + data: 42, + datacontenttype: 'application/xml', + }); + + expect(publishedEvent().textData).toBe('42'); + }); + + it('keeps an explicit content type for a string payload', async () => { + await publishMessage({ + ...BASE_OPTIONS, + data: '', + datacontenttype: 'application/xml', + }); + + expect(publishedAttributes()['datacontenttype']).toBe('application/xml'); + expect(publishedEvent().textData).toBe(''); + }); + + it('drops the attribute for an empty content type', async () => { + await publishMessage({...BASE_OPTIONS, data: 'hello', datacontenttype: ''}); + + expect(publishedAttributes()).not.toHaveProperty('datacontenttype'); + expect(publishedEvent().textData).toBe('hello'); + }); + + it('sends no payload for an empty string', async () => { + await publishMessage({...BASE_OPTIONS, data: ''}); + + expect(publishedEvent()).not.toHaveProperty('textData'); + expect(publishedEvent()).not.toHaveProperty('binaryData'); + expect(publishedAttributes()).not.toHaveProperty('datacontenttype'); + }); + + it('keeps an explicit content type even when there is no payload', async () => { + await publishMessage({...BASE_OPTIONS, datacontenttype: 'application/xml'}); + + expect(publishedAttributes()['datacontenttype']).toBe('application/xml'); + expect(publishedEvent()).not.toHaveProperty('textData'); + }); + + it('serialises an empty object', async () => { + await publishMessage({...BASE_OPTIONS, data: {}}); + + expect(publishedEvent().textData).toBe('{}'); + expect(publishedAttributes()['datacontenttype']).toBe('application/json'); + }); + + it('reports data that cannot be JSON-encoded', async () => { + const result = await publishMessage({...BASE_OPTIONS, data: 1n}); + + expect(expectError(result)).toContain('Failed to serialize data to JSON'); + }); + + it('reports a circular payload', async () => { + const circular: Record = {}; + circular['self'] = circular; + + const result = await publishMessage({...BASE_OPTIONS, data: circular}); + + expect(expectError(result)).toContain('Failed to serialize data to JSON'); + }); + + it('reports a payload that JSON.stringify silently drops', async () => { + const result = await publishMessage({...BASE_OPTIONS, data: Symbol('x')}); + + expect(expectError(result)).toContain('value is not JSON-serializable'); + }); + + it('reports a circular payload under a non-JSON content type', async () => { + const circular: Record = {}; + circular['self'] = circular; + + const result = await publishMessage({ + ...BASE_OPTIONS, + data: circular, + datacontenttype: 'application/xml', + }); + + expect(expectError(result)).toMatch(/^Failed to serialize data: /); + }); + + it('stringifies a symbol payload under a non-JSON content type', async () => { + await publishMessage({ + ...BASE_OPTIONS, + data: Symbol('x'), + datacontenttype: 'application/xml', + }); + + expect(publishedEvent().textData).toBe('Symbol(x)'); + }); +}); + +describe('publishMessage base64 payloads', () => { + it('decodes a base64 payload into bytes', async () => { + const result = await publishMessage({ + ...BASE_OPTIONS, + data: Buffer.from('hello bytes').toString('base64'), + isBase64Encoded: true, + }); + + expectSuccess(result); + expect(publishedAttributes()['datacontenttype']).toBe( + 'application/octet-stream', + ); + expect(Buffer.from(publishedEvent().binaryData ?? []).toString()).toBe( + 'hello bytes', + ); + }); + + it('tolerates whitespace inside a base64 payload', async () => { + const encoded = Buffer.from('hello bytes').toString('base64'); + const result = await publishMessage({ + ...BASE_OPTIONS, + data: `${encoded.slice(0, 4)}\n ${encoded.slice(4)}`, + isBase64Encoded: true, + }); + + expectSuccess(result); + expect(Buffer.from(publishedEvent().binaryData ?? []).toString()).toBe( + 'hello bytes', + ); + }); + + it('rejects a payload outside the base64 alphabet', async () => { + const result = await publishMessage({ + ...BASE_OPTIONS, + data: 'not!valid!base64!', + isBase64Encoded: true, + }); + + expect(expectError(result)).toContain('Invalid base64 string'); + expect(mocks.publish).not.toHaveBeenCalled(); + }); + + it('rejects a base64 payload with a truncated final group', async () => { + const result = await publishMessage({ + ...BASE_OPTIONS, + data: 'aGVsbG8', + isBase64Encoded: true, + }); + + expect(expectError(result)).toContain('Invalid base64 string'); + }); + + it('rejects a non-string base64 payload', async () => { + const result = await publishMessage({ + ...BASE_OPTIONS, + data: 123, + isBase64Encoded: true, + }); + + expect(expectError(result)).toBe( + 'data must be a string when is_base64_encoded is true', + ); + }); +}); + +describe('publishMessage attribute validation', () => { + it.each([ + { + name: 'empty type', + options: {type: ''}, + error: 'type must be a non-empty string', + }, + { + name: 'blank type', + options: {type: ' '}, + error: 'type must be a non-empty string', + }, + { + name: 'empty source', + options: {source: ''}, + error: 'source must be a non-empty string', + }, + { + name: 'blank id', + options: {id: ' '}, + error: 'id, if provided, must be a non-empty string', + }, + { + name: 'invalid custom attribute key', + options: {customAttributes: {'InvalidKey!': 'val'}}, + error: 'Invalid custom attribute key', + }, + { + name: 'invalid time format', + options: {time: 'invalid-time'}, + error: 'Invalid RFC 3339', + }, + { + name: 'date without a time component', + options: {time: '2026-06-03'}, + error: 'Invalid RFC 3339', + }, + ])('rejects $name', async ({options, error}) => { + const result = await publishMessage({...BASE_OPTIONS, ...options}); + + expect(expectError(result)).toContain(error); + expect(mocks.publish).not.toHaveBeenCalled(); + }); + + it.each([ + '2026-06-03T12:00:00Z', + '2026-06-03T12:00:00.123456Z', + '2026-06-03T12:00:00+00:00', + '2026-06-03T12:00:00-07:00', + '2026-06-03T12:00:00.123+02:00', + ])('accepts the RFC 3339 timestamp %s', async (time) => { + const result = await publishMessage({...BASE_OPTIONS, time}); + + expectSuccess(result); + expect(publishedAttributes()['time']).toBe(time); + }); + + it('drops the time attribute for an empty string', async () => { + await publishMessage({...BASE_OPTIONS, time: ''}); + + expect(publishedAttributes()).not.toHaveProperty('time'); + }); + + it('coerces custom attribute values to strings', async () => { + await publishMessage({ + ...BASE_OPTIONS, + customAttributes: {flag: true, count: 7, name: 'abc'}, + }); + + expect(publishedAttributes()).toMatchObject({ + flag: 'true', + count: '7', + name: 'abc', + }); + }); + + it('accepts digit-only custom attribute keys', async () => { + await publishMessage({...BASE_OPTIONS, customAttributes: {'123': 'ok'}}); + + expect(publishedAttributes()['123']).toBe('ok'); + }); +}); + +/** Minimal W3C-style propagator so the tracing extension has something to inject. */ +const TEST_PROPAGATOR: TextMapPropagator = { + inject(_context, carrier, setter) { + setter.set(carrier, 'traceparent', '00-trace-span-01'); + setter.set(carrier, 'tracestate', 'vendor=1'); + }, + extract(activeContext) { + return activeContext; + }, + fields() { + return ['traceparent', 'tracestate']; + }, +}; + +describe('publishMessage tracing extension', () => { + afterEach(() => { + propagation.disable(); + }); + + it('copies the active trace context into the attributes', async () => { + propagation.setGlobalPropagator(TEST_PROPAGATOR); + + await publishMessage({...BASE_OPTIONS, includeTracingExtension: true}); + + expect(publishedAttributes()).toMatchObject({ + traceparent: '00-trace-span-01', + tracestate: 'vendor=1', + }); + }); + + it('adds nothing when no propagator is registered', async () => { + await publishMessage({...BASE_OPTIONS, includeTracingExtension: true}); + + expect(publishedAttributes()).not.toHaveProperty('traceparent'); + expect(publishedAttributes()).not.toHaveProperty('tracestate'); + }); + + it('adds nothing when the tracing extension is not requested', async () => { + propagation.setGlobalPropagator(TEST_PROPAGATOR); + + await publishMessage(BASE_OPTIONS); + + expect(publishedAttributes()).not.toHaveProperty('traceparent'); + }); +}); + +describe('publishMessage failure handling', () => { + it('reports a missing SDK before validating anything else', async () => { + mocks.sdk.available = false; + + const result = await publishMessage({...BASE_OPTIONS, type: ''}); + + expect(expectError(result)).toBe( + '@google-cloud/eventarc-publishing is not installed', + ); + }); + + it('evicts the cached client when the publish call fails', async () => { + mocks.publish.mockRejectedValue(new Error('API failed')); + + const result = await publishMessage({ + ...BASE_OPTIONS, + toolConfig: {projectId: 'my-project'}, + }); + + expect(expectError(result)).toBe('API failed'); + expect(vi.mocked(removePublisherClient)).toHaveBeenCalledWith({ + credentialsConfig: undefined, + projectId: 'my-project', + }); + }); + + it('reports a non-Error rejection from the publisher client', async () => { + mocks.publish.mockRejectedValue('transport exploded'); + + const result = await publishMessage(BASE_OPTIONS); + + expect(expectError(result)).toBe('transport exploded'); + }); + + it('does not evict the cached client for a validation failure', async () => { + const result = await publishMessage({...BASE_OPTIONS, type: ''}); + + expect(expectError(result)).toBe('type must be a non-empty string'); + expect(vi.mocked(removePublisherClient)).not.toHaveBeenCalled(); + }); +}); + +describe('createPublishMessageTool', () => { + it('declares the model-facing CloudEvent parameters', () => { + const tool = createPublishMessageTool({}); + + expect(tool.name).toBe('publish_message'); + const parameters: Schema | undefined = tool._getDeclaration()?.parameters; + expect(parameters?.required).toEqual(['bus', 'type', 'source']); + expect(Object.keys(parameters?.properties ?? {})).toEqual([ + 'bus', + 'type', + 'source', + 'data', + 'is_base64_encoded', + 'include_tracing_extension', + 'datacontenttype', + 'specversion', + 'subject', + 'id', + 'time', + 'custom_attributes', + ]); + expect(parameters?.properties?.['data']?.type).toBe(Type.STRING); + expect(parameters?.properties?.['is_base64_encoded']?.type).toBe( + Type.BOOLEAN, + ); + expect(parameters?.properties?.['custom_attributes']?.type).toBe( + Type.OBJECT, + ); + }); + + it('publishes with the snake_case arguments the model supplies', async () => { + const result = await runTool({ + bus: BASE_OPTIONS.bus, + type: BASE_OPTIONS.type, + source: BASE_OPTIONS.source, + data: 'hello', + is_base64_encoded: false, + subject: 'orders/42', + custom_attributes: {region: 'eu'}, + }); + + expect(result).toEqual({ + status: 'SUCCESS', + message_id: expect.stringMatching(UUID_V4_PATTERN), + }); + expect(publishedAttributes()).toMatchObject({ + subject: 'orders/42', + region: 'eu', + }); + }); + + it('forwards the toolset configuration to the publisher client', async () => { + const credentialsConfig = {scopes: ['https://example.test/scope']}; + const tool = createPublishMessageTool({ + toolConfig: {projectId: 'configured-project', publishTimeoutMs: 5_000}, + credentialsConfig, + }); + + await tool.runAsync({ + args: { + bus: BASE_OPTIONS.bus, + type: BASE_OPTIONS.type, + source: BASE_OPTIONS.source, + }, + toolContext: TOOL_CONTEXT, + }); + + expect(vi.mocked(getPublisherClient)).toHaveBeenCalledWith({ + credentialsConfig, + projectId: 'configured-project', + }); + expect(mocks.publish.mock.calls[0][1]).toEqual({timeout: 5_000}); + }); + + it('decodes base64 data requested by the model', async () => { + await runTool({ + bus: BASE_OPTIONS.bus, + type: BASE_OPTIONS.type, + source: BASE_OPTIONS.source, + data: Buffer.from('bytes from the model').toString('base64'), + is_base64_encoded: true, + }); + + expect(Buffer.from(publishedEvent().binaryData ?? []).toString()).toBe( + 'bytes from the model', + ); + }); + + it('rejects a non-string time from the model', async () => { + const result = await runTool({ + bus: BASE_OPTIONS.bus, + type: BASE_OPTIONS.type, + source: BASE_OPTIONS.source, + time: 12345, + }); + + expect(result).toEqual({ + status: 'ERROR', + error_details: 'time must be a string', + }); + }); + + it('rejects non-object custom attributes from the model', async () => { + const result = await runTool({ + bus: BASE_OPTIONS.bus, + type: BASE_OPTIONS.type, + source: BASE_OPTIONS.source, + custom_attributes: 'not an object', + }); + + expect(result).toEqual({ + status: 'ERROR', + error_details: 'custom_attributes must be an object', + }); + }); + + it('rejects a non-string type from the model', async () => { + const result = await runTool({ + bus: BASE_OPTIONS.bus, + type: 123, + source: BASE_OPTIONS.source, + }); + + expect(result).toEqual({ + status: 'ERROR', + error_details: 'type must be a non-empty string', + }); + }); + + it('rejects a non-string source from the model', async () => { + const result = await runTool({ + bus: BASE_OPTIONS.bus, + type: BASE_OPTIONS.type, + source: {nested: true}, + }); + + expect(result).toEqual({ + status: 'ERROR', + error_details: 'source must be a non-empty string', + }); + }); + + it('rejects a non-string id from the model', async () => { + const result = await runTool({ + bus: BASE_OPTIONS.bus, + type: BASE_OPTIONS.type, + source: BASE_OPTIONS.source, + id: 42, + }); + + expect(result).toEqual({ + status: 'ERROR', + error_details: 'id, if provided, must be a non-empty string', + }); + }); + + it('ignores a non-string optional attribute the reference does not validate', async () => { + await runTool({ + bus: BASE_OPTIONS.bus, + type: BASE_OPTIONS.type, + source: BASE_OPTIONS.source, + datacontenttype: 7, + specversion: 7, + subject: 7, + }); + + expect(publishedEvent().specVersion).toBe('1.0'); + expect(publishedAttributes()).not.toHaveProperty('subject'); + expect(publishedAttributes()).not.toHaveProperty('datacontenttype'); + }); + + it('reports a missing type when the model sends no arguments', async () => { + const result = await createPublishMessageTool({}).runAsync({ + args: {}, + toolContext: TOOL_CONTEXT, + }); + + expect(result).toEqual({ + status: 'ERROR', + error_details: 'type must be a non-empty string', + }); + }); +}); diff --git a/core/test/utils/object_utils_test.ts b/core/test/utils/object_utils_test.ts new file mode 100644 index 000000000..0fa4cf047 --- /dev/null +++ b/core/test/utils/object_utils_test.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {describe, expect, it} from 'vitest'; + +import {isRecord} from '../../src/utils/object_utils.js'; + +describe('isRecord', () => { + it.each([{}, {a: 1}, Object.create(null), new Date()])( + 'accepts the object %o', + (value) => { + expect(isRecord(value)).toBe(true); + }, + ); + + it.each([null, undefined, 'text', 0, false, [], [1, 2], Symbol('x')])( + 'rejects the non-object %o', + (value) => { + expect(isRecord(value)).toBe(false); + }, + ); + + it('narrows the value so its properties can be read', () => { + const value: unknown = {answer: 42}; + + expect(isRecord(value) ? value['answer'] : undefined).toBe(42); + }); +}); diff --git a/package-lock.json b/package-lock.json index e29a4a190..ed66a8b20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,6 +75,7 @@ "zod-to-json-schema": "^3.25.1" }, "devDependencies": { + "@google-cloud/eventarc-publishing": "^4.3.0", "@mikro-orm/sqlite": "^6.6.6", "@types/adm-zip": "^0.5.8", "@types/express": "^4.17.25", @@ -82,11 +83,17 @@ "openapi-types": "^12.1.3" }, "peerDependencies": { + "@google-cloud/eventarc-publishing": "^4.3.0", "@mikro-orm/mariadb": "^6.6.6", "@mikro-orm/mssql": "^6.6.6", "@mikro-orm/mysql": "^6.6.6", "@mikro-orm/postgresql": "^6.6.6", "@mikro-orm/sqlite": "^6.6.6" + }, + "peerDependenciesMeta": { + "@google-cloud/eventarc-publishing": { + "optional": true + } } }, "dev": { @@ -1345,6 +1352,19 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@google-cloud/eventarc-publishing": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@google-cloud/eventarc-publishing/-/eventarc-publishing-4.3.0.tgz", + "integrity": "sha512-w/HGIp3tz8NXCer98u+ayWPYkANrVyBZFUNXtcRHs8EGTzDmdtDv4H2Xym5qIA93QALitr7GCRPMqaa/PEHtug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@google-cloud/opentelemetry-cloud-monitoring-exporter": { "version": "0.21.0", "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-cloud-monitoring-exporter/-/opentelemetry-cloud-monitoring-exporter-0.21.0.tgz", @@ -8371,6 +8391,200 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/google-gax": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.8.tgz", + "integrity": "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/google-gax/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/google-gax/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax/node_modules/retry-request": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.4.tgz", + "integrity": "sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-gax/node_modules/teeny-request": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.4.tgz", + "integrity": "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/google-logging-utils": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", @@ -11158,6 +11372,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -11872,6 +12096,19 @@ "node": ">=10" } }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/protobufjs": { "version": "7.6.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", diff --git a/package.json b/package.json index 36cc0d747..39be27edb 100644 --- a/package.json +++ b/package.json @@ -73,5 +73,10 @@ "**/*.{json,md}": [ "prettier --write" ] + }, + "overrides": { + "google-gax": { + "google-auth-library": "^10.7.0" + } } }