Files
openclaw/extensions/google-meet/src/config.ts
Peter Steinberger 964c8c84c1 refactor: consolidate coercion ownership (#122299)
* refactor: consolidate coercion ownership

Centralize four canonical coercion helpers, migrate exact core and plugin duplicates through narrow Plugin SDK facades, and enforce declaration and plugin-normalization ownership boundaries.

The sweep adds eight focused SDK exports while deleting more production and tooling code than it adds. User-visible behavior is unchanged except for safer equivalent object and UI parsing at existing boundaries.

* fix: guard integer option ownership

Register resolveIntegerOption with the canonical function owner and extend the declaration-guard fixture so future local duplicates fail validation.

* fix: keep integer helpers on numeric facade

Remove the unshipped duplicate string-coerce exports and route every affected plugin consumer through the existing number-runtime contract.

* fix: point numeric coercion to number runtime

Make boundary and declaration diagnostics recommend the canonical numeric facade, with failing-before coverage for both guidance paths.
2026-08-11 17:14:53 -07:00

567 lines
20 KiB
TypeScript

// Google Meet helper module supports config behavior.
import {
addTimerTimeoutGraceMs,
resolvePositiveTimerTimeoutMs,
} from "openclaw/plugin-sdk/number-runtime";
import {
REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
resolveRealtimeVoiceAgentConsultToolPolicy,
type RealtimeVoiceAgentConsultToolPolicy,
} from "openclaw/plugin-sdk/realtime-voice";
import {
asBoolean,
asRecord,
normalizeOptionalLowercaseString,
normalizeOptionalString,
normalizeOptionalTrimmedStringList,
parseBooleanValue,
} from "openclaw/plugin-sdk/string-coerce-runtime";
export type GoogleMeetTransport = "chrome" | "chrome-node" | "twilio";
export type GoogleMeetMode = "agent" | "bidi" | "transcribe";
export type GoogleMeetModeInput = GoogleMeetMode | "realtime";
type GoogleMeetRealtimeStrategy = "agent" | "bidi";
type GoogleMeetChromeAudioFormat = "pcm16-24khz" | "g711-ulaw-8khz";
type GoogleMeetToolPolicy = RealtimeVoiceAgentConsultToolPolicy;
type MeetingAudioBackendSelection = "auto" | "blackhole-2ch" | "pipewire-pulse";
export type GoogleMeetConfig = {
enabled: boolean;
defaults: {
meeting?: string;
};
preview: {
enrollmentAcknowledged: boolean;
};
defaultTransport: GoogleMeetTransport;
defaultMode: GoogleMeetMode;
chrome: {
audioBackend: MeetingAudioBackendSelection;
audioFormat: GoogleMeetChromeAudioFormat;
audioBufferBytes: number;
launch: boolean;
browserProfile?: string;
guestName: string;
reuseExistingTab: boolean;
autoJoin: boolean;
joinTimeoutMs: number;
waitForInCallMs: number;
audioInputCommand?: string[];
audioOutputCommand?: string[];
audioInputCommandOverride?: string[];
audioOutputCommandOverride?: string[];
bargeInInputCommand?: string[];
bargeInRmsThreshold: number;
bargeInPeakThreshold: number;
bargeInCooldownMs: number;
audioBridgeCommand?: string[];
audioBridgeHealthCommand?: string[];
};
chromeNode: {
node?: string;
};
twilio: {
defaultDialInNumber?: string;
defaultPin?: string;
defaultDtmfSequence?: string;
};
voiceCall: {
enabled: boolean;
gatewayUrl?: string;
token?: string;
requestTimeoutMs: number;
dtmfDelayMs: number;
postDtmfSpeechDelayMs: number;
introMessage?: string;
};
realtime: {
strategy: GoogleMeetRealtimeStrategy;
provider?: string;
transcriptionProvider?: string;
voiceProvider?: string;
model?: string;
instructions?: string;
introMessage?: string;
agentId?: string;
toolPolicy: GoogleMeetToolPolicy;
providers: Record<string, Record<string, unknown>>;
};
oauth: {
clientId?: string;
clientSecret?: string;
refreshToken?: string;
accessToken?: string;
expiresAt?: number;
};
auth: {
provider: "google-oauth";
clientId?: string;
clientSecret?: string;
tokenPath?: string;
};
};
export function resolveGoogleMeetGatewayOperationTimeoutMs(config: GoogleMeetConfig): number {
return Math.max(
60_000,
addTimerTimeoutGraceMs(config.chrome.joinTimeoutMs, 30_000) ?? 1,
addTimerTimeoutGraceMs(config.voiceCall.requestTimeoutMs, 10_000) ?? 1,
);
}
const SOX_DEFAULT_BUFFER_BYTES = 8192;
const SOX_MIN_BUFFER_BYTES = 17;
const DEFAULT_GOOGLE_MEET_AUDIO_BUFFER_BYTES = SOX_DEFAULT_BUFFER_BYTES / 2;
const PLAIN_DECIMAL_NUMBER_RE = /^\d+(?:\.\d+)?$/;
function buildGoogleMeetAudioCommands(
backend: MeetingAudioBackendSelection,
format: GoogleMeetChromeAudioFormat,
bufferBytes: number,
) {
// Config parsing runs during registration; command construction must not load
// the full meeting runtime before a Google Meet action needs it.
const pipeWire =
backend === "pipewire-pulse" || (backend === "auto" && process.platform === "linux");
const sampleRate = format === "g711-ulaw-8khz" ? 8_000 : 24_000;
const bits = format === "g711-ulaw-8khz" ? 8 : 16;
if (pipeWire) {
const pulseFormat = format === "g711-ulaw-8khz" ? "ulaw" : "s16le";
const latencyMs = Math.max(
1,
Math.ceil((bufferBytes / (sampleRate * Math.ceil(bits / 8))) * 1_000),
);
const common = [
"--device=openclaw_meeting_audio",
`--format=${pulseFormat}`,
`--rate=${sampleRate}`,
"--channels=1",
`--latency-msec=${latencyMs}`,
];
return {
inputCommand: ["parec", "--raw", ...common],
outputCommand: ["pacat", "--raw", "--playback", ...common],
};
}
const wire =
format === "g711-ulaw-8khz"
? ["-t", "raw", "-r", "8000", "-c", "1", "-e", "mu-law", "-b", "8", "-"]
: ["-t", "raw", "-r", "24000", "-c", "1", "-e", "signed-integer", "-b", "16", "-L", "-"];
const withBuffer = (executable: string, args: string[]) => [
executable,
"-q",
"--buffer",
String(bufferBytes),
...args,
];
return {
inputCommand: withBuffer("sox", ["-t", "coreaudio", "BlackHole 2ch", ...wire]),
outputCommand: withBuffer("sox", [...wire, "-t", "coreaudio", "BlackHole 2ch"]),
};
}
const DEFAULT_GOOGLE_MEET_AUDIO_COMMANDS = buildGoogleMeetAudioCommands(
"blackhole-2ch",
"pcm16-24khz",
DEFAULT_GOOGLE_MEET_AUDIO_BUFFER_BYTES,
);
export const DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND =
DEFAULT_GOOGLE_MEET_AUDIO_COMMANDS.inputCommand;
export const DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND =
DEFAULT_GOOGLE_MEET_AUDIO_COMMANDS.outputCommand;
const DEFAULT_GOOGLE_MEET_CHROME_AUDIO_FORMAT: GoogleMeetChromeAudioFormat = "pcm16-24khz";
const DEFAULT_GOOGLE_MEET_BARGE_IN_RMS_THRESHOLD = 650;
const DEFAULT_GOOGLE_MEET_BARGE_IN_PEAK_THRESHOLD = 2500;
const DEFAULT_GOOGLE_MEET_BARGE_IN_COOLDOWN_MS = 900;
const DEFAULT_GOOGLE_MEET_REALTIME_INSTRUCTIONS = `You are joining a private Google Meet as an OpenClaw voice transport. Keep spoken replies brief and natural. In agent mode, wait for OpenClaw consult results and speak them exactly. In bidi mode, answer directly and call ${REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME} for deeper reasoning, current information, or tools.`;
const DEFAULT_GOOGLE_MEET_REALTIME_INTRO_MESSAGE = "Say exactly: I'm here and listening.";
const DEFAULT_GOOGLE_MEET_CONFIG: GoogleMeetConfig = {
enabled: true,
defaults: {},
preview: {
enrollmentAcknowledged: false,
},
defaultTransport: "chrome",
defaultMode: "agent",
chrome: {
audioBackend: "auto",
audioFormat: DEFAULT_GOOGLE_MEET_CHROME_AUDIO_FORMAT,
audioBufferBytes: DEFAULT_GOOGLE_MEET_AUDIO_BUFFER_BYTES,
launch: true,
guestName: "OpenClaw Agent",
reuseExistingTab: true,
autoJoin: true,
joinTimeoutMs: 30_000,
waitForInCallMs: 20_000,
audioInputCommand: [...DEFAULT_GOOGLE_MEET_AUDIO_INPUT_COMMAND],
audioOutputCommand: [...DEFAULT_GOOGLE_MEET_AUDIO_OUTPUT_COMMAND],
bargeInRmsThreshold: DEFAULT_GOOGLE_MEET_BARGE_IN_RMS_THRESHOLD,
bargeInPeakThreshold: DEFAULT_GOOGLE_MEET_BARGE_IN_PEAK_THRESHOLD,
bargeInCooldownMs: DEFAULT_GOOGLE_MEET_BARGE_IN_COOLDOWN_MS,
},
chromeNode: {},
twilio: {},
voiceCall: {
enabled: true,
requestTimeoutMs: 30_000,
dtmfDelayMs: 12_000,
postDtmfSpeechDelayMs: 5_000,
},
realtime: {
strategy: "agent",
provider: "openai",
transcriptionProvider: "openai",
instructions: DEFAULT_GOOGLE_MEET_REALTIME_INSTRUCTIONS,
introMessage: DEFAULT_GOOGLE_MEET_REALTIME_INTRO_MESSAGE,
toolPolicy: "safe-read-only",
providers: {},
},
oauth: {},
auth: {
provider: "google-oauth",
},
};
const GOOGLE_MEET_CLIENT_ID_KEYS = ["OPENCLAW_GOOGLE_MEET_CLIENT_ID", "GOOGLE_MEET_CLIENT_ID"];
const GOOGLE_MEET_CLIENT_SECRET_KEYS = [
"OPENCLAW_GOOGLE_MEET_CLIENT_SECRET",
"GOOGLE_MEET_CLIENT_SECRET",
] as const;
const GOOGLE_MEET_REFRESH_TOKEN_KEYS = [
"OPENCLAW_GOOGLE_MEET_REFRESH_TOKEN",
"GOOGLE_MEET_REFRESH_TOKEN",
] as const;
const GOOGLE_MEET_ACCESS_TOKEN_KEYS = [
"OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN",
"GOOGLE_MEET_ACCESS_TOKEN",
] as const;
const GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT_KEYS = [
"OPENCLAW_GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT",
"GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT",
] as const;
const GOOGLE_MEET_DEFAULT_MEETING_KEYS = [
"OPENCLAW_GOOGLE_MEET_DEFAULT_MEETING",
"GOOGLE_MEET_DEFAULT_MEETING",
] as const;
const GOOGLE_MEET_PREVIEW_ACK_KEYS = [
"OPENCLAW_GOOGLE_MEET_PREVIEW_ACK",
"GOOGLE_MEET_PREVIEW_ACK",
] as const;
function resolveBoolean(value: unknown, fallback: boolean): boolean {
return asBoolean(value) ?? fallback;
}
function resolveNumber(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function resolveTimerConfigMs(value: unknown, fallback: number): number {
return resolvePositiveTimerTimeoutMs(resolveNumber(value, fallback), fallback);
}
function resolveOptionalNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const trimmed = value.trim();
const parsed = PLAIN_DECIMAL_NUMBER_RE.test(trimmed) ? Number(trimmed) : Number.NaN;
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function readEnvString(env: NodeJS.ProcessEnv, keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = normalizeOptionalString(env[key]);
if (value) {
return value;
}
}
return undefined;
}
function normalizeStringAllowEmpty(value: unknown): string | undefined {
return typeof value === "string" ? value.trim() : undefined;
}
function readEnvBoolean(env: NodeJS.ProcessEnv, keys: readonly string[]): boolean | undefined {
return parseBooleanValue(readEnvString(env, keys));
}
function readEnvNumber(env: NodeJS.ProcessEnv, keys: readonly string[]): number | undefined {
return resolveOptionalNumber(readEnvString(env, keys));
}
function resolveStringArray(value: unknown): string[] | undefined {
return normalizeOptionalTrimmedStringList(value);
}
function resolveProvidersConfig(value: unknown): Record<string, Record<string, unknown>> {
const raw = asRecord(value);
const providers: Record<string, Record<string, unknown>> = {};
for (const [key, entry] of Object.entries(raw)) {
const providerId = normalizeOptionalLowercaseString(key);
if (!providerId) {
continue;
}
providers[providerId] = asRecord(entry);
}
return providers;
}
function resolveTransport(value: unknown, fallback: GoogleMeetTransport): GoogleMeetTransport {
const normalized = normalizeOptionalLowercaseString(value);
return normalized === "chrome" || normalized === "chrome-node" || normalized === "twilio"
? normalized
: fallback;
}
function resolveMode(value: unknown, fallback: GoogleMeetMode): GoogleMeetMode {
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "realtime") {
return "agent";
}
return normalized === "agent" || normalized === "bidi" || normalized === "transcribe"
? normalized
: fallback;
}
function resolveRealtimeStrategy(
value: unknown,
fallback: GoogleMeetRealtimeStrategy,
): GoogleMeetRealtimeStrategy {
const normalized = normalizeOptionalLowercaseString(value);
return normalized === "agent" || normalized === "bidi" ? normalized : fallback;
}
function resolveChromeAudioFormat(value: unknown): GoogleMeetChromeAudioFormat | undefined {
const normalized = normalizeOptionalString(value)?.toLowerCase().replaceAll("_", "-");
switch (normalized) {
case "pcm16-24khz":
case "pcm16-24k":
case "pcm24":
case "pcm":
return "pcm16-24khz";
case "g711-ulaw-8khz":
case "g711-ulaw-8k":
case "g711-ulaw":
case "mulaw":
case "mu-law":
return "g711-ulaw-8khz";
default:
return undefined;
}
}
function resolveAudioBufferBytes(value: unknown, fallback: number): number {
const number = resolveNumber(value, fallback);
if (!Number.isFinite(number) || number <= 0) {
return fallback;
}
return Math.max(SOX_MIN_BUFFER_BYTES, Math.trunc(number));
}
function defaultAudioInputCommand(
backend: MeetingAudioBackendSelection,
format: GoogleMeetChromeAudioFormat,
bufferBytes: number,
): string[] {
return buildGoogleMeetAudioCommands(backend, format, bufferBytes).inputCommand;
}
function defaultAudioOutputCommand(
backend: MeetingAudioBackendSelection,
format: GoogleMeetChromeAudioFormat,
bufferBytes: number,
): string[] {
return buildGoogleMeetAudioCommands(backend, format, bufferBytes).outputCommand;
}
function resolveAudioBackend(value: unknown): MeetingAudioBackendSelection {
const normalized = normalizeOptionalLowercaseString(value)?.replaceAll("_", "-");
return normalized === "blackhole-2ch" || normalized === "pipewire-pulse" ? normalized : "auto";
}
export function resolveGoogleMeetConfig(input: unknown): GoogleMeetConfig {
return resolveGoogleMeetConfigWithEnv(input);
}
function resolveGoogleMeetConfigWithEnv(
input: unknown,
env: NodeJS.ProcessEnv = process.env,
): GoogleMeetConfig {
const raw = asRecord(input);
const defaults = asRecord(raw.defaults);
const preview = asRecord(raw.preview);
const chrome = asRecord(raw.chrome);
const configuredAudioInputCommand = resolveStringArray(chrome.audioInputCommand);
const configuredAudioOutputCommand = resolveStringArray(chrome.audioOutputCommand);
const hasCustomAudioCommand =
configuredAudioInputCommand !== undefined || configuredAudioOutputCommand !== undefined;
const audioFormat =
resolveChromeAudioFormat(chrome.audioFormat) ??
(hasCustomAudioCommand ? "g711-ulaw-8khz" : DEFAULT_GOOGLE_MEET_CONFIG.chrome.audioFormat);
const audioBufferBytes = resolveAudioBufferBytes(
chrome.audioBufferBytes,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.audioBufferBytes,
);
const audioBackend = resolveAudioBackend(chrome.audioBackend);
const chromeNode = asRecord(raw.chromeNode);
const twilio = asRecord(raw.twilio);
const voiceCall = asRecord(raw.voiceCall);
const realtime = asRecord(raw.realtime);
const realtimeProvider = normalizeOptionalString(realtime.provider);
const resolvedRealtimeProvider = realtimeProvider ?? DEFAULT_GOOGLE_MEET_CONFIG.realtime.provider;
const oauth = asRecord(raw.oauth);
const auth = asRecord(raw.auth);
return {
enabled: resolveBoolean(raw.enabled, DEFAULT_GOOGLE_MEET_CONFIG.enabled),
defaults: {
meeting:
normalizeOptionalString(defaults.meeting) ??
readEnvString(env, GOOGLE_MEET_DEFAULT_MEETING_KEYS),
},
preview: {
enrollmentAcknowledged: resolveBoolean(
preview.enrollmentAcknowledged,
readEnvBoolean(env, GOOGLE_MEET_PREVIEW_ACK_KEYS) ??
DEFAULT_GOOGLE_MEET_CONFIG.preview.enrollmentAcknowledged,
),
},
defaultTransport: resolveTransport(
raw.defaultTransport,
DEFAULT_GOOGLE_MEET_CONFIG.defaultTransport,
),
defaultMode: resolveMode(raw.defaultMode, DEFAULT_GOOGLE_MEET_CONFIG.defaultMode),
chrome: {
audioBackend,
audioFormat,
audioBufferBytes,
launch: resolveBoolean(chrome.launch, DEFAULT_GOOGLE_MEET_CONFIG.chrome.launch),
browserProfile: normalizeOptionalString(chrome.browserProfile),
guestName:
normalizeOptionalString(chrome.guestName) ?? DEFAULT_GOOGLE_MEET_CONFIG.chrome.guestName,
reuseExistingTab: resolveBoolean(
chrome.reuseExistingTab,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.reuseExistingTab,
),
autoJoin: resolveBoolean(chrome.autoJoin, DEFAULT_GOOGLE_MEET_CONFIG.chrome.autoJoin),
joinTimeoutMs: resolveTimerConfigMs(
chrome.joinTimeoutMs,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.joinTimeoutMs,
),
waitForInCallMs: resolveTimerConfigMs(
chrome.waitForInCallMs,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.waitForInCallMs,
),
audioInputCommand:
configuredAudioInputCommand ??
defaultAudioInputCommand(audioBackend, audioFormat, audioBufferBytes),
audioOutputCommand:
configuredAudioOutputCommand ??
defaultAudioOutputCommand(audioBackend, audioFormat, audioBufferBytes),
audioInputCommandOverride: configuredAudioInputCommand,
audioOutputCommandOverride: configuredAudioOutputCommand,
bargeInInputCommand: resolveStringArray(chrome.bargeInInputCommand),
bargeInRmsThreshold: resolveNumber(
chrome.bargeInRmsThreshold,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInRmsThreshold,
),
bargeInPeakThreshold: resolveNumber(
chrome.bargeInPeakThreshold,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInPeakThreshold,
),
bargeInCooldownMs: resolveTimerConfigMs(
chrome.bargeInCooldownMs,
DEFAULT_GOOGLE_MEET_CONFIG.chrome.bargeInCooldownMs,
),
audioBridgeCommand: resolveStringArray(chrome.audioBridgeCommand),
audioBridgeHealthCommand: resolveStringArray(chrome.audioBridgeHealthCommand),
},
chromeNode: {
node: normalizeOptionalString(chromeNode.node),
},
twilio: {
defaultDialInNumber: normalizeOptionalString(twilio.defaultDialInNumber),
defaultPin: normalizeOptionalString(twilio.defaultPin),
defaultDtmfSequence: normalizeOptionalString(twilio.defaultDtmfSequence),
},
voiceCall: {
enabled: resolveBoolean(voiceCall.enabled, DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.enabled),
gatewayUrl: normalizeOptionalString(voiceCall.gatewayUrl),
token: normalizeOptionalString(voiceCall.token),
requestTimeoutMs: resolveTimerConfigMs(
voiceCall.requestTimeoutMs,
DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.requestTimeoutMs,
),
dtmfDelayMs: resolveTimerConfigMs(
voiceCall.dtmfDelayMs,
DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.dtmfDelayMs,
),
postDtmfSpeechDelayMs: resolveTimerConfigMs(
voiceCall.postDtmfSpeechDelayMs,
DEFAULT_GOOGLE_MEET_CONFIG.voiceCall.postDtmfSpeechDelayMs,
),
introMessage: normalizeOptionalString(voiceCall.introMessage),
},
realtime: {
strategy: resolveRealtimeStrategy(
realtime.strategy,
DEFAULT_GOOGLE_MEET_CONFIG.realtime.strategy,
),
provider: resolvedRealtimeProvider,
transcriptionProvider:
normalizeOptionalString(realtime.transcriptionProvider) ??
(realtimeProvider && realtimeProvider !== "google"
? resolvedRealtimeProvider
: DEFAULT_GOOGLE_MEET_CONFIG.realtime.transcriptionProvider),
voiceProvider: normalizeOptionalString(realtime.voiceProvider),
model: normalizeOptionalString(realtime.model) ?? DEFAULT_GOOGLE_MEET_CONFIG.realtime.model,
instructions:
normalizeOptionalString(realtime.instructions) ??
DEFAULT_GOOGLE_MEET_CONFIG.realtime.instructions,
introMessage:
normalizeStringAllowEmpty(realtime.introMessage) ??
DEFAULT_GOOGLE_MEET_CONFIG.realtime.introMessage,
agentId: normalizeOptionalString(realtime.agentId),
toolPolicy: resolveRealtimeVoiceAgentConsultToolPolicy(
realtime.toolPolicy,
DEFAULT_GOOGLE_MEET_CONFIG.realtime.toolPolicy,
),
providers: resolveProvidersConfig(realtime.providers),
},
oauth: {
clientId:
normalizeOptionalString(oauth.clientId) ??
normalizeOptionalString(auth.clientId) ??
readEnvString(env, GOOGLE_MEET_CLIENT_ID_KEYS),
clientSecret:
normalizeOptionalString(oauth.clientSecret) ??
normalizeOptionalString(auth.clientSecret) ??
readEnvString(env, GOOGLE_MEET_CLIENT_SECRET_KEYS),
refreshToken:
normalizeOptionalString(oauth.refreshToken) ??
readEnvString(env, GOOGLE_MEET_REFRESH_TOKEN_KEYS),
accessToken:
normalizeOptionalString(oauth.accessToken) ??
readEnvString(env, GOOGLE_MEET_ACCESS_TOKEN_KEYS),
expiresAt:
resolveOptionalNumber(oauth.expiresAt) ??
readEnvNumber(env, GOOGLE_MEET_ACCESS_TOKEN_EXPIRES_AT_KEYS),
},
auth: {
provider: "google-oauth",
clientId: normalizeOptionalString(auth.clientId),
clientSecret: normalizeOptionalString(auth.clientSecret),
tokenPath: normalizeOptionalString(auth.tokenPath),
},
};
}