diff --git a/ci/reviewed-npm-lifecycle-allowlist.json b/ci/reviewed-npm-lifecycle-allowlist.json
index 323ad5e83b..df6a4d333f 100644
--- a/ci/reviewed-npm-lifecycle-allowlist.json
+++ b/ci/reviewed-npm-lifecycle-allowlist.json
@@ -5,6 +5,7 @@
"@openclaw/brave-plugin@2026.6.10",
"@openclaw/diagnostics-otel@2026.6.10",
"@openclaw/discord@2026.6.10",
+ "@openclaw/googlechat@2026.6.10",
"@openclaw/msteams@2026.6.10",
"@openclaw/slack@2026.6.10",
"@openclaw/whatsapp@2026.6.10",
diff --git a/docs/index.yml b/docs/index.yml
index 90331149c1..55e15f524e 100644
--- a/docs/index.yml
+++ b/docs/index.yml
@@ -215,6 +215,9 @@ navigation:
- page: "Set Up Slack"
path: _build/agent-variants/manage-sandboxes/set-up-slack.openclaw.generated.mdx
slug: set-up-slack
+ - page: "Set Up Google Chat"
+ path: manage-sandboxes/set-up-google-chat.mdx
+ slug: set-up-google-chat
- page: "Set Up WeChat"
path: _build/agent-variants/manage-sandboxes/set-up-wechat.openclaw.generated.mdx
slug: set-up-wechat
diff --git a/docs/manage-sandboxes/add-channels-after-onboarding.mdx b/docs/manage-sandboxes/add-channels-after-onboarding.mdx
index 754da5a9f6..5743077139 100644
--- a/docs/manage-sandboxes/add-channels-after-onboarding.mdx
+++ b/docs/manage-sandboxes/add-channels-after-onboarding.mdx
@@ -32,6 +32,16 @@ $$nemoclaw my-assistant channels add whatsapp
$$nemoclaw my-assistant channels add teams
```
+
+Add experimental Google Chat through its interactive enrollment flow:
+
+```bash
+$$nemoclaw my-assistant channels add googlechat
+```
+
+Refer to [Set Up Google Chat](set-up-google-chat) before running the command.
+
+
`channels add` accepts mixed-case input such as `Telegram`, then stores and prints the canonical lowercase name.
It collects the inputs the channel needs, registers bridge providers with the OpenShell gateway when it captures tokens, records the channel in the sandbox registry, and asks whether to rebuild immediately.
@@ -39,6 +49,11 @@ Telegram, Discord, Slack, and Microsoft Teams prompt for credentials and configu
WeChat runs an interactive host-side QR scan.
WhatsApp collects no token because pairing happens inside the rebuilt sandbox.
+
+Google Chat prompts for a service-account JSON key and pauses while you configure its public webhook endpoint.
+It cannot be added non-interactively.
+
+
## Apply Policy and Rebuild
`channels add` requires the matching built-in network policy preset YAML.
diff --git a/docs/manage-sandboxes/enable-channels-during-onboarding.mdx b/docs/manage-sandboxes/enable-channels-during-onboarding.mdx
index 67e8aa74ee..9d3feedb0d 100644
--- a/docs/manage-sandboxes/enable-channels-during-onboarding.mdx
+++ b/docs/manage-sandboxes/enable-channels-during-onboarding.mdx
@@ -9,6 +9,8 @@ keywords: ["nemoclaw onboard messaging", "messaging channel picker", "channel en
content:
type: "how_to"
---
+import { AgentOnly } from "../_components/AgentGuide";
+
Enable channels during onboarding when you are creating or recreating a sandbox.
## Use the Interactive Picker
@@ -16,6 +18,12 @@ Enable channels during onboarding when you are creating or recreating a sandbox.
When the wizard reaches **Messaging channels**, it lists Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams when the selected agent supports them.
Press a channel number to toggle it on or off, then press **Enter** when done.
+
+The OpenClaw picker also lists experimental Google Chat.
+Google Chat enrollment is interactive because you must set and confirm the public HTTP endpoint in Google Cloud Console.
+Refer to [Set Up Google Chat](set-up-google-chat) before selecting it.
+
+
If you select no channels, pressing **Enter** skips messaging setup.
If a token-based channel token is not already in the environment or credential store, the wizard prompts for it and saves it.
@@ -56,6 +64,11 @@ This release does not support non-interactive WeChat configuration because the i
Run `$$nemoclaw onboard` interactively when you want to enable WeChat.
For non-interactive WhatsApp selection, set `WHATSAPP_ALLOWED_IDS` to a nonempty comma-separated sender list.
+
+Google Chat also requires interactive enrollment.
+NemoClaw skips it in non-interactive mode even when you supply its environment variables.
+
+
## Run Onboarding
```bash
diff --git a/docs/manage-sandboxes/manage-messaging-channels.mdx b/docs/manage-sandboxes/manage-messaging-channels.mdx
index 147026622d..a8522959d9 100644
--- a/docs/manage-sandboxes/manage-messaging-channels.mdx
+++ b/docs/manage-sandboxes/manage-messaging-channels.mdx
@@ -21,6 +21,11 @@ Rebuild the sandbox after the update so the image reflects the current channel s
For WeChat, the cached-token shortcut applies.
Remove WeChat first when you intend to acquire a fresh account through a new QR scan.
+
+For Google Chat, re-add the channel and paste the replacement service-account JSON.
+NemoClaw updates the gateway-side refresh material, and the sandbox continues to receive only gateway-minted access tokens.
+
+
For detailed token rotation procedures, refer to [Credential Rotation](../../security/credential-rotation).
## Remove a Channel
@@ -36,6 +41,11 @@ $$nemoclaw my-assistant channels remove teams
`channels remove wechat` clears the bot token, deletes the `-wechat-bridge` provider, and removes `wechat` from the enabled-channel set.
The next rebuild omits WeChat configuration and per-account state files.
+
+`channels remove googlechat` detaches and deletes the `-googlechat-bridge` provider before the rebuild removes Google Chat configuration and the matching policy preset.
+It also stops the dedicated cloudflared process and webhook proxy for that sandbox.
+
+
For in-sandbox QR-paired channels such as WhatsApp, `channels remove` destructively clears the session directory before rebuild so stale auth files do not reconnect the channel.
@@ -76,6 +86,12 @@ For WeChat, `channels stop wechat` followed by rebuild keeps the per-account sta
A later `channels start wechat` plus rebuild revives the bridge against the same iLink account without a fresh QR scan.
The bot token remains in the OpenShell provider across the stop and start cycle.
+
+Google Chat stop and start cycles also preserve the bridge provider and its gateway-side refresh material.
+They preserve the dedicated public webhook endpoint so the Google Cloud configuration can keep the same URL.
+The next rebuild reuses that provider without requiring the service-account JSON again.
+
+
When `channels start` re-enables a channel, NemoClaw reapplies the matching built-in policy preset before rebuild.
If policy restoration fails, the command keeps the channel disabled and exits without rebuilding into a partially active state.
diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx
index ce90d2d75f..b4f7c41c01 100644
--- a/docs/manage-sandboxes/messaging-channels.mdx
+++ b/docs/manage-sandboxes/messaging-channels.mdx
@@ -3,9 +3,9 @@
# SPDX-License-Identifier: Apache-2.0
title: "Choose Messaging Channels"
sidebar-title: "Choose Messaging Channels"
-description: "Compare the supported Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams messaging paths."
+description: "Compare the supported messaging paths, including experimental Google Chat for OpenClaw."
description-agent: "Compares supported and experimental messaging channels, their credential or pairing requirements, and their NemoClaw runtime boundaries. Use when choosing a channel for OpenClaw or Hermes."
-keywords: ["nemoclaw messaging channels", "telegram", "discord", "slack", "wechat", "whatsapp", "microsoft teams"]
+keywords: ["nemoclaw messaging channels", "telegram", "discord", "slack", "google chat", "wechat", "whatsapp", "microsoft teams"]
content:
type: "concept"
skill:
@@ -14,6 +14,7 @@ skill:
import { AgentOnly } from "../_components/AgentGuide";
NemoClaw supports Telegram, Discord, Slack, WeChat, WhatsApp, and Microsoft Teams for OpenClaw and Hermes sandboxes.
+Google Chat is available for OpenClaw sandboxes only.
OpenShell-managed processes and gateway resources carry channel traffic.
For token-based channels, NemoClaw registers credentials with OpenShell providers.
@@ -25,10 +26,10 @@ Microsoft Teams uses Bot Framework credentials plus a public HTTPS webhook that
NemoClaw writes the selected channel configuration into the sandbox image and keeps runtime delivery under OpenShell control.
-WeChat, WhatsApp, and Microsoft Teams are experimental.
+Google Chat, WeChat, WhatsApp, and Microsoft Teams are experimental.
WeChat and WhatsApp rely on QR-based pairing flows that are more fragile than token-based bots.
-Microsoft Teams requires an externally reachable webhook path that depends on your host networking setup.
+Google Chat and Microsoft Teams require externally reachable webhook paths that depend on your host networking setup.
Interfaces, defaults, and supported features can change, and NVIDIA does not recommend these channels for production use.
@@ -43,6 +44,11 @@ Interfaces, defaults, and supported features can change, and NVIDIA does not rec
| WhatsApp | In-sandbox QR pairing | Hermes sender allowlist and non-interactive channel selection | [Set Up WhatsApp](set-up-whatsapp) |
| Microsoft Teams | `MSTEAMS_APP_ID`, `MSTEAMS_APP_PASSWORD`, `MSTEAMS_TENANT_ID` | User allowlist, webhook port, mention mode | [Set Up Microsoft Teams](set-up-microsoft-teams) |
+
+Google Chat requires a service-account JSON key and a public HTTPS endpoint for the OpenClaw webhook.
+Refer to [Set Up Google Chat](set-up-google-chat) for the interactive tunnel, audience, and account-principal flow.
+
+
## Prepare the Host and Policy
- Use a machine where you can run `$$nemoclaw onboard`.
diff --git a/docs/manage-sandboxes/set-up-google-chat.mdx b/docs/manage-sandboxes/set-up-google-chat.mdx
new file mode 100644
index 0000000000..b1232e8318
--- /dev/null
+++ b/docs/manage-sandboxes/set-up-google-chat.mdx
@@ -0,0 +1,112 @@
+---
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+title: "Set Up Google Chat"
+sidebar-title: "Set Up Google Chat"
+description: "Configure the experimental Google Chat webhook, service account, access controls, and OpenShell credential boundary for OpenClaw."
+description-agent: "Explains the experimental OpenClaw-only Google Chat setup, including its interactive public webhook flow, service-account key, audience, DM allowlist, personal-account principal discovery, and gateway-minted outbound token. Use before enabling Google Chat."
+keywords: ["nemoclaw google chat", "google chat api bot", "google chat webhook", "google chat service account"]
+content:
+ type: "how_to"
+---
+Google Chat support is experimental and available only for OpenClaw sandboxes.
+It receives events at `/googlechat` on the OpenClaw dashboard port and sends replies through the Google Chat API.
+
+
+The automatic public endpoint accepts only `POST /googlechat` and denies dashboard, health, WebSocket, and other control paths.
+Continue to open the Control UI through `http://127.0.0.1:18789`; the Google Chat URL is not a dashboard URL.
+
+
+## Prepare the Google Cloud Configuration
+
+Prepare the Google Cloud resources before you enable the channel.
+
+- Enable the Google Chat API and configure the Chat app that the sandbox will run.
+- Create a service account and download a JSON key.
+- Minify the service-account JSON to one line before you paste it at the `GOOGLECHAT_SERVICE_ACCOUNT` prompt.
+- Choose an HTTP endpoint connection in **Google Chat API** > **Configuration** > **Connection settings**.
+
+Keep the JSON key available on the trusted host during enrollment.
+Do not copy it into the sandbox or its OpenClaw configuration.
+
+## Prepare the Public Webhook
+
+The default `app-url` audience flow starts a dedicated cloudflared service in front of a loopback-only webhook proxy.
+The proxy forwards only `POST /googlechat` to OpenClaw and limits webhook request bodies to 1 MiB.
+It is separate from `nemoclaw tunnel start`, which remains the explicit full-dashboard tunnel command.
+Install `cloudflared` on the host before enrollment when you do not already have a public endpoint.
+
+When the dedicated Google Chat tunnel is not running, NemoClaw starts it and prints the exact HTTPS endpoint ending in `/googlechat`.
+Copy that complete URL into the Google Chat API HTTP endpoint field without adding a trailing slash, then confirm the prompt.
+NemoClaw stops the dedicated tunnel and webhook proxy if you do not confirm the endpoint or if enrollment fails before confirmation.
+
+If `GOOGLECHAT_AUDIENCE` already contains the public webhook URL, NemoClaw uses it and does not start or change cloudflared.
+That operator-managed endpoint must preserve `/googlechat` and deny every other dashboard or control path before forwarding to OpenClaw.
+
+Google Chat enrollment always requires an interactive terminal.
+NemoClaw skips the channel when `NEMOCLAW_NON_INTERACTIVE=1`, even if you provide the audience and service-account environment variables.
+
+## Configure Access
+
+Leave `GOOGLECHAT_ALLOWED_USERS` empty to use OpenClaw's manual DM pairing flow.
+To use an allowlist, set it to comma-separated Google Chat user IDs such as `users/123456789`, not email addresses.
+Google Chat email addresses do not match this ID-based allowlist.
+
+Google Workspace accounts do not need `GOOGLECHAT_APP_PRINCIPAL`.
+Leave that prompt empty unless you use a personal or standalone Google account.
+
+## Enable Google Chat
+
+For a new sandbox, run `nemoclaw onboard` and select Google Chat in the messaging picker.
+For an existing OpenClaw sandbox, run:
+
+```bash
+nemoclaw my-assistant channels add googlechat
+```
+
+Follow the prompts to confirm the public endpoint, paste the service-account JSON, configure access, and rebuild the sandbox.
+NemoClaw applies the `googlechat` network policy preset and registers a sandbox-scoped `-googlechat-bridge` provider with OpenShell.
+
+OpenShell uses the service-account key as gateway-side refresh material to mint short-lived tokens with the `chat.bot` scope.
+NemoClaw passes the private key to the OpenShell command through an ephemeral child-process environment value, not through a command-line argument.
+The service-account private key does not enter the sandbox, and the OpenShell proxy inserts the minted bearer token into allowed requests to `chat.googleapis.com`.
+
+The channel policy permits Node.js to read the Google Chat REST API and to create, update, or delete messages under the `/v1/spaces/` tree.
+It also permits Node.js GET requests to any path on `www.googleapis.com` because the Google authentication library controls the public certificate URL used to verify inbound event tokens.
+
+## Complete Personal Account Discovery
+
+Personal or standalone Google accounts need the add-on's numeric app principal.
+This value is a Google-assigned numeric ID, not an email address.
+
+If you do not know the value, leave `GOOGLECHAT_APP_PRINCIPAL` empty during the first enrollment, then start the rebuilt sandbox and follow these steps:
+
+1. Watch the OpenClaw logs for the discovery message.
+
+ ```bash
+ nemoclaw my-assistant logs --follow | grep "unexpected add-on principal"
+ ```
+
+2. Send one direct message to the bot.
+ The bot does not reply during this discovery attempt.
+
+3. Copy the numeric value from `unexpected add-on principal: `.
+
+4. Re-add the channel with the value and accept the rebuild prompt.
+
+ ```bash
+ GOOGLECHAT_APP_PRINCIPAL="" nemoclaw my-assistant channels add googlechat
+ ```
+
+The re-add flow prompts for the service-account JSON again when it is not already present in the current host environment.
+
+## Verify the Channel
+
+After the rebuild, send a direct message from an allowed or paired account and confirm that OpenClaw replies.
+If the webhook returns an error, verify that the public endpoint still ends in `/googlechat`, the dedicated tunnel and webhook proxy are running, and the Google Chat API configuration contains the exact same URL.
+
+Use the host-side `channels stop`, `channels start`, and `channels remove` commands to manage the integration.
+`channels stop googlechat` leaves the dedicated endpoint running so `channels start googlechat` can keep the same Google Cloud endpoint URL.
+`channels remove googlechat` and `nemoclaw destroy` stop the dedicated cloudflared process and webhook proxy.
+`nemoclaw tunnel stop` does not stop the dedicated Google Chat endpoint; it controls the separate full-dashboard tunnel.
+Refer to [Manage Messaging Channels](manage-messaging-channels) for lifecycle behavior.
diff --git a/src/lib/actions/sandbox/destroy.ts b/src/lib/actions/sandbox/destroy.ts
index 071b8605e8..26645e9597 100644
--- a/src/lib/actions/sandbox/destroy.ts
+++ b/src/lib/actions/sandbox/destroy.ts
@@ -61,6 +61,7 @@ export type CleanupSandboxServicesDeps = {
unloadOllamaModels?: () => void;
runOpenshell?: RunOpenshell;
rmSync?: typeof fs.rmSync;
+ stopGooglechatWebhookTunnel?: (sandboxName: string) => string;
};
type ShieldsTimerNeutralizeResult = {
@@ -137,6 +138,13 @@ export function cleanupSandboxServices(
return runtime.runOpenshell(args, opts);
});
const rmSync = deps.rmSync ?? fs.rmSync;
+ const stopGooglechatWebhookTunnel =
+ deps.stopGooglechatWebhookTunnel ??
+ ((name: string) => {
+ const lifecycle =
+ require("../../tunnel/googlechat-webhook-lifecycle") as typeof import("../../tunnel/googlechat-webhook-lifecycle");
+ return lifecycle.stopGooglechatWebhookTunnel(name);
+ });
if (stopHostServices) {
// `stopAll()` already runs `unloadOllamaModels()` unconditionally —
@@ -152,6 +160,14 @@ export function cleanupSandboxServices(
}
}
+ let googlechatServicesPidDir = `${servicesPidDir}-googlechat`;
+ try {
+ googlechatServicesPidDir = stopGooglechatWebhookTunnel(validatedSandboxName);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.warn(` ${YW}⚠${R} Failed to stop Google Chat webhook tunnel: ${message}`);
+ }
+
try {
rmSync(servicesPidDir, {
recursive: true,
@@ -160,6 +176,14 @@ export function cleanupSandboxServices(
} catch {
// PID directory may not exist — ignore.
}
+ try {
+ rmSync(googlechatServicesPidDir, {
+ recursive: true,
+ force: true,
+ });
+ } catch {
+ // Dedicated Google Chat service directory may not exist — ignore.
+ }
// Delete every per-sandbox messaging and search provider created during
// onboard. Suppress stderr so "! Provider not found" noise doesn't appear
diff --git a/src/lib/actions/sandbox/policy-channel-dependencies.ts b/src/lib/actions/sandbox/policy-channel-dependencies.ts
index 4be90427f3..f3c506bfc1 100644
--- a/src/lib/actions/sandbox/policy-channel-dependencies.ts
+++ b/src/lib/actions/sandbox/policy-channel-dependencies.ts
@@ -24,6 +24,7 @@ type LegacyOnboardProvidersModule = {
};
type RebuildModule = typeof import("./rebuild");
+type GooglechatWebhookLifecycleModule = typeof import("../../tunnel/googlechat-webhook-lifecycle");
/**
* Injectable, late-bound boundary around provider registration and rebuild
@@ -47,4 +48,9 @@ export const policyChannelDependencies = {
const rebuild = require("./rebuild") as RebuildModule;
return rebuild.rebuildSandbox(sandboxName, args);
},
+ stopGooglechatWebhookTunnel(sandboxName: string): void {
+ const lifecycle =
+ require("../../tunnel/googlechat-webhook-lifecycle") as GooglechatWebhookLifecycleModule;
+ lifecycle.stopGooglechatWebhookTunnel(sandboxName);
+ },
};
diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts
index 99bb780904..2ba5490da0 100644
--- a/src/lib/actions/sandbox/policy-channel.ts
+++ b/src/lib/actions/sandbox/policy-channel.ts
@@ -7,7 +7,11 @@ import { runOpenshell } from "../../adapters/openshell/runtime";
import { type AgentDefinition, loadAgent } from "../../agent/defs";
import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding";
import { isNonInteractiveEnv } from "../../core/non-interactive";
-import { prompt as askPrompt, getCredential } from "../../credentials/store";
+import {
+ prompt as askPrompt,
+ getCredential,
+ normalizeCredentialValue,
+} from "../../credentials/store";
import {
type PolicyAddOptions,
type PolicyRemoveOptions,
@@ -36,7 +40,13 @@ import {
import { findChannelConflicts } from "../../messaging/applier/conflict-detection/registry";
import { hydrateMessagingChannelConfig } from "../../messaging-channel-config";
import { filterSetupPolicyPresetsForAgent } from "../../onboard/agent-policy-presets";
+import {
+ bridgeProviderNamesForChannel,
+ bridgeSecretEnvsForChannel,
+ collectMessagingBridgeTokenDefs,
+} from "../../onboard/messaging-bridge-provider";
import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config";
+import type { MessagingTokenDef } from "../../onboard/messaging-prep";
import { getMessagingToken } from "../../onboard/messaging-token";
import * as policies from "../../policy";
import { formatPolicyListPresetRow } from "../../policy/policy-list-display";
@@ -542,27 +552,75 @@ async function applyChannelAddToGatewayAndRegistry(
sandboxName: string,
channelName: string,
acquired: Record,
-): Promise {
- const tokenDefs = Object.entries(acquired).map(([envKey, token]) => ({
+): Promise {
+ const tokenDefs: MessagingTokenDef[] = Object.entries(acquired).map(([envKey, token]) => ({
name: bridgeProviderName(sandboxName, channelName, envKey),
envKey,
token,
}));
- if (tokenDefs.length > 0) {
- const gatewayName = getSandboxTargetGatewayName(sandboxName);
- const recovery = await recoverNamedGatewayRuntime({ gatewayName });
- if (!recovery.recovered) {
+ // Bridge channels declare no manifest credentials, so the loop above yields
+ // nothing for them. Their provider must be created HERE (same seam onboarding
+ // uses): the pasted secret is env-only and gone once this process exits, so a
+ // deferred rebuild cannot configure it.
+ const bridgeDefs = collectMessagingBridgeTokenDefs({
+ sandboxName,
+ enabledChannels: [channelName],
+ disabledChannelNames: new Set(),
+ getCredential,
+ env: process.env,
+ // Env-map values (string | undefined) fit the store helper's input union.
+ normalizeCredentialValue: (value) => normalizeCredentialValue(value as string | undefined),
+ });
+ if (
+ bridgeDefs.length === 0 &&
+ bridgeProviderNamesForChannel(sandboxName, channelName).length > 0
+ ) {
+ const secretEnvs = bridgeSecretEnvsForChannel(channelName);
+ console.error(
+ ` ✗ ${channelName} mints its outbound token gateway-side and needs ${secretEnvs.join(", ")} to configure it.`,
+ );
+ console.error(
+ " Paste the secret at the enrollment prompt or export the env var, then re-run.",
+ );
+ process.exit(1);
+ }
+ tokenDefs.push(...bridgeDefs);
+ if (tokenDefs.length === 0) return false;
+
+ const gatewayName = getSandboxTargetGatewayName(sandboxName);
+ const recovery = await recoverNamedGatewayRuntime({ gatewayName });
+ if (!recovery.recovered) {
+ console.error(
+ ` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway. Tokens were staged`,
+ );
+ console.error(" in env for this run only — re-run after starting the gateway, or run");
+ console.error(` 'openshell gateway start --name ${gatewayName}' manually.`);
+ process.exit(1);
+ }
+ try {
+ // bestEffort: failures throw (instead of process.exit inside the helper)
+ // so a partial add can be torn down below before exiting.
+ policyChannelDependencies.upsertMessagingProviders(tokenDefs, { bestEffort: true });
+ } catch (err) {
+ console.error(
+ ` ✗ Failed to register '${channelName}' providers with the gateway: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ const teardown = await applyChannelRemoveToGatewayAndRegistry(
+ sandboxName,
+ channelName,
+ Object.keys(acquired),
+ { bestEffort: true },
+ );
+ if (!teardown.ok) {
console.error(
- ` Could not reach the ${CLI_DISPLAY_NAME} OpenShell gateway. Tokens were staged`,
+ ` ${YW}⚠${R} Partial provider state may remain; run '${CLI_NAME} ${sandboxName} channels remove ${channelName}' once the gateway is reachable.`,
);
- console.error(" in env for this run only — re-run after starting the gateway, or run");
- console.error(` 'openshell gateway start --name ${gatewayName}' manually.`);
- process.exit(1);
}
- // upsertMessagingProviders handles create-or-update and process.exits on
- // failure, so reaching the next line means every entry is registered.
- policyChannelDependencies.upsertMessagingProviders(tokenDefs);
+ process.exit(1);
}
+ return true;
}
// Remove a channel's bridge providers from the gateway and drop it from the
@@ -577,7 +635,18 @@ async function applyChannelRemoveToGatewayAndRegistry(
const residual: string[] = [];
let gatewayReachable = true;
- if (channelTokenKeys.length > 0) {
+ // Providers to tear down: the per-credential providers PLUS the gateway-minted
+ // bridge provider for a bridge-backed channel (which has no channelTokenKeys, so
+ // it would otherwise be left dangling — still minting/rotating a token for a
+ // removed channel). Discovery is generic (bridge module), by convention.
+ const providerNames = [
+ ...new Set([
+ ...channelTokenKeys.map((envKey) => bridgeProviderName(sandboxName, channelName, envKey)),
+ ...bridgeProviderNamesForChannel(sandboxName, channelName),
+ ]),
+ ];
+
+ if (providerNames.length > 0) {
const gatewayName = getSandboxTargetGatewayName(sandboxName);
const recovery = await recoverNamedGatewayRuntime({ gatewayName });
if (!recovery.recovered) {
@@ -603,8 +672,7 @@ async function applyChannelRemoveToGatewayAndRegistry(
// configured for a sandbox that is no longer alive.
const detachFailures: Array<{ name: string; output: string }> = [];
if (gatewayReachable) {
- for (const envKey of channelTokenKeys) {
- const name = bridgeProviderName(sandboxName, channelName, envKey);
+ for (const name of providerNames) {
const result = runOpenshell(["sandbox", "provider", "detach", sandboxName, name], {
ignoreError: true,
stdio: ["ignore", "pipe", "pipe"],
@@ -640,8 +708,7 @@ async function applyChannelRemoveToGatewayAndRegistry(
const deleteFailures: Array<{ name: string; output: string }> = [];
if (gatewayReachable) {
const detachFailedSet = new Set(detachFailures.map((f) => f.name));
- for (const envKey of channelTokenKeys) {
- const name = bridgeProviderName(sandboxName, channelName, envKey);
+ for (const name of providerNames) {
if (!bestEffort && detachFailedSet.has(name)) continue;
const result = runOpenshell(["provider", "delete", name], {
ignoreError: true,
@@ -1043,8 +1110,14 @@ async function addSandboxChannelUnlocked(
// discard the change. Pre-fix this was safe because saveCredential()
// wrote credentials.json; with env-only persistence, exiting before
// the rebuild used to drop the queued token.
- await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, acquired);
- console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`);
+ const registeredBridge = await applyChannelAddToGatewayAndRegistry(
+ sandboxName,
+ canonical,
+ acquired,
+ );
+ if (registeredBridge) {
+ console.log(` ${G}✓${R} Registered ${canonical} bridge with the OpenShell gateway.`);
+ }
if (!applyChannelPresetIfAvailable(sandboxName, canonical)) {
await rollbackChannelAdd(sandboxName, channelDef, canonical, {
@@ -1090,6 +1163,13 @@ async function rollbackChannelAdd(
console.error(
` ${YW}⚠${R} Rollback could not fully clean ${residual.join(", ")}; run '${CLI_NAME} ${sandboxName} channels remove ${canonical}' once the gateway is reachable.`,
);
+ // The prior bridge secret is env-only and gone — the failed re-add already
+ // overwrote the gateway's refresh material, so a restore is impossible.
+ if (bridgeProviderNamesForChannel(sandboxName, canonical).length > 0) {
+ console.error(
+ ` ${YW}⚠${R} The gateway bridge provider keeps the newly configured key material; re-run '${CLI_NAME} ${sandboxName} channels add ${canonical}' to converge.`,
+ );
+ }
if (Object.keys(snapshot.priorCreds).length > 0) {
try {
const priorTokenDefs = Object.entries(snapshot.priorCreds).map(([envKey, token]) => ({
@@ -1387,6 +1467,15 @@ async function removeSandboxChannelUnlocked(
process.exit(1);
}
+ if (canonical === "googlechat") {
+ try {
+ policyChannelDependencies.stopGooglechatWebhookTunnel(sandboxName);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(` ${YW}⚠${R} Could not stop the Google Chat webhook tunnel: ${message}`);
+ }
+ }
+
// Token-based channels: best-effort tidy of any leftover dir. Token
// revocation already prevents the bot from authenticating, so a
// failure here is a warning, not a bail.
diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts
index a9678dac99..80ae224370 100644
--- a/src/lib/messaging-channel-config.test.ts
+++ b/src/lib/messaging-channel-config.test.ts
@@ -31,6 +31,10 @@ describe("messaging channel config", () => {
"MSTEAMS_APP_ID",
"MSTEAMS_TENANT_ID",
"MSTEAMS_PORT",
+ "GOOGLECHAT_AUDIENCE_TYPE",
+ "GOOGLECHAT_AUDIENCE",
+ "GOOGLECHAT_APP_PRINCIPAL",
+ "GOOGLECHAT_ALLOWED_USERS",
]);
});
diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts
index ffd14f67bd..63cddad727 100755
--- a/src/lib/messaging/applier/build/messaging-build-applier.mts
+++ b/src/lib/messaging/applier/build/messaging-build-applier.mts
@@ -21,6 +21,7 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
import { pathToFileURL } from "node:url";
import { packReviewedNpmArchive } from "../../../../../scripts/lib/reviewed-npm-archive.mts";
import { discordManifest } from "../../channels/discord/manifest.ts";
+import { googlechatManifest } from "../../channels/googlechat/manifest.ts";
import { slackManifest } from "../../channels/slack/manifest.ts";
import { teamsManifest } from "../../channels/teams/manifest.ts";
import { telegramManifest } from "../../channels/telegram/manifest.ts";
@@ -155,6 +156,7 @@ const TRUSTED_CHANNEL_MANIFESTS: readonly ChannelManifest[] = [
slackManifest,
whatsappManifest,
teamsManifest,
+ googlechatManifest,
] as const;
function isPinnedHermesUvPackageSpec(spec: string): boolean {
diff --git a/src/lib/messaging/channels/built-ins.ts b/src/lib/messaging/channels/built-ins.ts
index aeb8439114..ad7b26a90a 100644
--- a/src/lib/messaging/channels/built-ins.ts
+++ b/src/lib/messaging/channels/built-ins.ts
@@ -4,16 +4,18 @@
import type { ChannelManifestRegistry } from "../manifest";
import { createChannelManifestRegistry } from "../manifest";
import { discordManifest } from "./discord/manifest";
+import { googlechatManifest } from "./googlechat/manifest";
import { slackManifest } from "./slack/manifest";
-import { telegramManifest } from "./telegram/manifest";
import { teamsManifest } from "./teams/manifest";
+import { telegramManifest } from "./telegram/manifest";
import { wechatManifest } from "./wechat/manifest";
import { whatsappManifest } from "./whatsapp/manifest";
export { discordManifest } from "./discord/manifest";
+export { googlechatManifest } from "./googlechat/manifest";
export { slackManifest } from "./slack/manifest";
-export { telegramManifest } from "./telegram/manifest";
export { teamsManifest } from "./teams/manifest";
+export { telegramManifest } from "./telegram/manifest";
export { wechatManifest } from "./wechat/manifest";
export { whatsappManifest } from "./whatsapp/manifest";
@@ -24,6 +26,7 @@ export const BUILT_IN_CHANNEL_MANIFESTS = [
slackManifest,
whatsappManifest,
teamsManifest,
+ googlechatManifest,
] as const;
export function createBuiltInChannelManifestRegistry(): ChannelManifestRegistry {
diff --git a/src/lib/messaging/channels/googlechat/hooks/index.ts b/src/lib/messaging/channels/googlechat/hooks/index.ts
new file mode 100644
index 0000000000..df75f46c8b
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/hooks/index.ts
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { MessagingHookRegistration } from "../../../hooks/types";
+import {
+ createGooglechatTunnelAudienceGateHookRegistration,
+ type GooglechatTunnelAudienceGateHookOptions,
+} from "./tunnel-audience-gate";
+import { createDefaultGooglechatTunnelGateOptions } from "./tunnel-runtime";
+
+export * from "./tunnel-audience-gate";
+
+export interface GooglechatHookOptions {
+ readonly tunnelAudienceGate?: GooglechatTunnelAudienceGateHookOptions;
+}
+
+export function createGooglechatHookRegistrations(
+ options: GooglechatHookOptions = {},
+): readonly MessagingHookRegistration[] {
+ const gateOptions = {
+ ...createDefaultGooglechatTunnelGateOptions(),
+ ...withoutUndefinedValues(options.tunnelAudienceGate),
+ };
+ return [createGooglechatTunnelAudienceGateHookRegistration(gateOptions)] as const;
+}
+
+function withoutUndefinedValues(
+ options: GooglechatTunnelAudienceGateHookOptions | undefined,
+): GooglechatTunnelAudienceGateHookOptions {
+ return Object.fromEntries(
+ Object.entries(options ?? {}).filter(([, value]) => value !== undefined),
+ ) as GooglechatTunnelAudienceGateHookOptions;
+}
diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts
new file mode 100644
index 0000000000..07b31ba523
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.test.ts
@@ -0,0 +1,163 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+
+import type { MessagingHookContext } from "../../../hooks/types";
+import type { MessagingSerializableValue } from "../../../manifest";
+import {
+ createGooglechatTunnelAudienceGateHook,
+ type GooglechatTunnelAudienceGateHookOptions,
+} from "./tunnel-audience-gate";
+
+function gateContext(
+ inputs: Record,
+ isInteractive = true,
+): MessagingHookContext {
+ return {
+ channelId: "googlechat",
+ hookId: "googlechat-tunnel-audience-gate",
+ phase: "enroll",
+ isInteractive,
+ inputs,
+ };
+}
+
+function baseOptions(
+ overrides: Partial = {},
+): GooglechatTunnelAudienceGateHookOptions {
+ return {
+ env: {},
+ log: () => {},
+ hasCloudflared: () => true,
+ readTunnelState: () => ({ running: false }),
+ startTunnel: async () => {},
+ stopTunnel: vi.fn(),
+ getTunnelUrl: () => "https://abc.trycloudflare.com",
+ prompt: async () => "y",
+ ...overrides,
+ };
+}
+
+describe("googlechat tunnel/audience gate hook", () => {
+ it("ignores non-googlechat channels", async () => {
+ const hook = createGooglechatTunnelAudienceGateHook(baseOptions());
+ const result = await hook({ ...gateContext({}), channelId: "slack" });
+ expect(result).toEqual({});
+ });
+
+ it("uses a pre-supplied audience without touching the tunnel", async () => {
+ const startTunnel = vi.fn(async () => {});
+ const stopTunnel = vi.fn();
+ const hook = createGooglechatTunnelAudienceGateHook(
+ baseOptions({ startTunnel, stopTunnel, readTunnelState: () => ({ running: false }) }),
+ );
+
+ const result = await hook(gateContext({ audience: "https://named.example.com/googlechat" }));
+
+ expect(result).toEqual({
+ outputs: { audience: { kind: "config", value: "https://named.example.com/googlechat" } },
+ });
+ expect(startTunnel).not.toHaveBeenCalled();
+ expect(stopTunnel).not.toHaveBeenCalled();
+ });
+
+ it("defers non app-url audience types to the config prompt", async () => {
+ const startTunnel = vi.fn(async () => {});
+ const hook = createGooglechatTunnelAudienceGateHook(baseOptions({ startTunnel }));
+
+ const result = await hook(gateContext({ audienceType: "project-number" }));
+
+ expect(result).toEqual({});
+ expect(startTunnel).not.toHaveBeenCalled();
+ });
+
+ it("always skips (throws) in non-interactive mode, even with an explicit audience", async () => {
+ const startTunnel = vi.fn(async () => {});
+ const stopTunnel = vi.fn();
+ const hook = createGooglechatTunnelAudienceGateHook(baseOptions({ startTunnel, stopTunnel }));
+
+ // No audience → skip.
+ await expect(hook(gateContext({}, false))).rejects.toThrow(/interactive enrollment required/);
+ // A pre-supplied audience does NOT bypass the skip: the Google Cloud Console
+ // endpoint + appPrincipal steps still need an operator, so Google Chat is
+ // unconditionally skipped in non-interactive mode (mirrors WeChat host QR).
+ await expect(
+ hook(gateContext({ audience: "https://named.example.com/googlechat" }, false)),
+ ).rejects.toThrow(/interactive enrollment required/);
+ // Never touches the tunnel in non-interactive mode.
+ expect(startTunnel).not.toHaveBeenCalled();
+ expect(stopTunnel).not.toHaveBeenCalled();
+ });
+
+ it("skips when cloudflared is not installed and never starts a tunnel", async () => {
+ const startTunnel = vi.fn(async () => {});
+ const hook = createGooglechatTunnelAudienceGateHook(
+ baseOptions({ hasCloudflared: () => false, startTunnel }),
+ );
+ await expect(hook(gateContext({}))).rejects.toThrow(/cloudflared is not installed/);
+ expect(startTunnel).not.toHaveBeenCalled();
+ });
+
+ it("starts a tunnel, derives the audience, and keeps the tunnel on confirmation", async () => {
+ let running = false;
+ const env: NodeJS.ProcessEnv = {};
+ const stopTunnel = vi.fn();
+ const hook = createGooglechatTunnelAudienceGateHook(
+ baseOptions({
+ env,
+ readTunnelState: () => ({ running }),
+ startTunnel: async () => {
+ running = true;
+ },
+ // Trailing slash must be stripped before appending the webhook path.
+ getTunnelUrl: () => "https://abc.trycloudflare.com/",
+ prompt: async () => "yes",
+ stopTunnel,
+ }),
+ );
+
+ const result = await hook(gateContext({}));
+
+ expect(result).toEqual({
+ outputs: { audience: { kind: "config", value: "https://abc.trycloudflare.com/googlechat" } },
+ });
+ expect(env.GOOGLECHAT_AUDIENCE).toBe("https://abc.trycloudflare.com/googlechat");
+ expect(stopTunnel).not.toHaveBeenCalled();
+ });
+
+ it("stops a self-started tunnel when the operator declines", async () => {
+ let running = false;
+ const stopTunnel = vi.fn();
+ const hook = createGooglechatTunnelAudienceGateHook(
+ baseOptions({
+ readTunnelState: () => ({ running }),
+ startTunnel: async () => {
+ running = true;
+ },
+ prompt: async () => "n",
+ stopTunnel,
+ }),
+ );
+
+ await expect(hook(gateContext({}))).rejects.toThrow(/did not confirm/);
+ expect(stopTunnel).toHaveBeenCalledTimes(1);
+ });
+
+ it("never stops a pre-existing tunnel on decline", async () => {
+ const startTunnel = vi.fn(async () => {});
+ const stopTunnel = vi.fn();
+ const hook = createGooglechatTunnelAudienceGateHook(
+ baseOptions({
+ readTunnelState: () => ({ running: true }),
+ startTunnel,
+ prompt: async () => "n",
+ stopTunnel,
+ }),
+ );
+
+ await expect(hook(gateContext({}))).rejects.toThrow(/did not confirm/);
+ expect(startTunnel).not.toHaveBeenCalled();
+ expect(stopTunnel).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts
new file mode 100644
index 0000000000..39f5125f5d
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts
@@ -0,0 +1,165 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type {
+ MessagingHookHandler,
+ MessagingHookOutputMap,
+ MessagingHookRegistration,
+} from "../../../hooks/types";
+import type { MessagingSerializableValue } from "../../../manifest";
+
+export const GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID = "googlechat.tunnelAudienceGate";
+
+// The tunnel audience path; must match the webhookPath rendered in manifest.ts
+// (the plugin's inbound route) or Google posts to a path the gateway doesn't serve.
+const DEFAULT_WEBHOOK_PATH = "/googlechat";
+
+/** Coarse cloudflared running-state used to decide whether we must start one. */
+export type GooglechatTunnelState = { readonly running: boolean };
+
+// Every side effect is injected so this hook file stays free of fs/process/
+// credential imports (mirrors the WeChat ilink-login pattern). The real
+// implementations live in ./tunnel-runtime and are wired by ./index.
+export interface GooglechatTunnelAudienceGateHookOptions {
+ readonly env?: NodeJS.ProcessEnv;
+ readonly log?: (message: string) => void;
+ readonly hasCloudflared?: () => boolean;
+ readonly readTunnelState?: () => GooglechatTunnelState;
+ readonly startTunnel?: () => Promise;
+ readonly stopTunnel?: () => void;
+ readonly getTunnelUrl?: () => string;
+ readonly prompt?: (question: string) => Promise;
+}
+
+function readString(value: MessagingSerializableValue | undefined): string {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function isAffirmative(value: string): boolean {
+ const normalized = value.trim().toLowerCase();
+ return normalized === "y" || normalized === "yes";
+}
+
+function audienceOutput(audience: string): { readonly outputs: MessagingHookOutputMap } {
+ const outputs: Record = {
+ audience: { kind: "config", value: audience },
+ };
+ return { outputs };
+}
+
+function printEndpointInstructions(log: (message: string) => void, audience: string): void {
+ log("");
+ log(" ── Google Chat — action required ───────────────────────────────");
+ log(" In Google Cloud Console → Google Chat API → Configuration →");
+ log(" Connection settings, set the HTTP endpoint URL to exactly:");
+ log(` ${audience}`);
+ log(" (HTTPS, exact match including the path — no trailing slash.)");
+ log("");
+}
+
+export function createGooglechatTunnelAudienceGateHook(
+ options: GooglechatTunnelAudienceGateHookOptions = {},
+): MessagingHookHandler {
+ return async (context) => {
+ if (context.channelId !== "googlechat") return {};
+
+ const env = options.env ?? process.env;
+ const log = options.log ?? ((message: string) => console.log(message));
+
+ // Non-interactive mode: always skip Google Chat. Enrollment needs manual,
+ // out-of-band steps that no environment variable can satisfy — the operator
+ // must paste the webhook URL into the Google Cloud Console and confirm it,
+ // and personal/standalone accounts must trace the appPrincipal from the
+ // first live DM. Like WeChat's host-QR login there is no unattended path, so
+ // we skip rather than enroll a half-configured channel that silently 404s on
+ // inbound webhooks. A pre-supplied GOOGLECHAT_AUDIENCE does NOT bypass this —
+ // the Console/appPrincipal steps still require a human.
+ if (context.isInteractive === false) {
+ log(
+ " Skipped googlechat (interactive setup required: Google Cloud Console endpoint URL + appPrincipal)",
+ );
+ throw new Error(
+ "Skipping Google Chat: interactive enrollment required (Cloud Console endpoint URL + appPrincipal cannot be supplied non-interactively).",
+ );
+ }
+
+ const audienceType = readString(context.inputs?.audienceType) || "app-url";
+
+ // An audience supplied up front (env, prior paste, or a named tunnel) wins;
+ // never touch the cloudflared tunnel in that case. Also covers project-number.
+ const existingAudience =
+ readString(context.inputs?.audience) || readString(env.GOOGLECHAT_AUDIENCE);
+ if (existingAudience) return audienceOutput(existingAudience);
+
+ // Only the app-url path derives its audience from a public webhook URL. Any
+ // other audienceType (e.g. project-number) is entered via the config prompt.
+ if (audienceType !== "app-url") return {};
+
+ const readTunnelState = requireOption(options.readTunnelState, "readTunnelState");
+ const getTunnelUrl = requireOption(options.getTunnelUrl, "getTunnelUrl");
+ const stopTunnel = requireOption(options.stopTunnel, "stopTunnel");
+
+ const preexisting = readTunnelState().running;
+ let startedByUs = false;
+ if (!preexisting) {
+ const hasCloudflared = requireOption(options.hasCloudflared, "hasCloudflared");
+ if (!hasCloudflared()) {
+ log(" Skipped googlechat (cloudflared not installed — needed for a public webhook URL)");
+ throw new Error(
+ "cloudflared is not installed; cannot expose a public Google Chat webhook.",
+ );
+ }
+ log(" Google Chat needs a public HTTPS URL. Starting a dedicated webhook tunnel…");
+ const startTunnel = requireOption(options.startTunnel, "startTunnel");
+ await startTunnel();
+ if (!readTunnelState().running) {
+ stopTunnel();
+ log(" Skipped googlechat (cloudflared tunnel failed to start)");
+ throw new Error("cloudflared tunnel failed to start.");
+ }
+ startedByUs = true;
+ }
+
+ const url = getTunnelUrl();
+ if (!url) {
+ if (startedByUs) stopTunnel();
+ log(" Skipped googlechat (no public tunnel URL available)");
+ throw new Error("No public tunnel URL is available for the Google Chat webhook.");
+ }
+
+ const audience = `${url.replace(/\/+$/, "")}${DEFAULT_WEBHOOK_PATH}`;
+ printEndpointInstructions(log, audience);
+
+ // Non-interactive mode already threw at the top of the hook, so this prompt
+ // path is only reached interactively. Mirrors promptYesNoOrDefault: default
+ // No, y/yes wins.
+ const prompt = requireOption(options.prompt, "prompt");
+ const answer = await prompt(
+ " Have you set this as the HTTP endpoint URL in Google Cloud Console? [y/N]: ",
+ );
+ if (!isAffirmative(answer)) {
+ if (startedByUs) stopTunnel();
+ log(" Skipped googlechat (HTTP endpoint URL not set in Google Cloud Console)");
+ throw new Error("Operator did not confirm the Google Chat HTTP endpoint URL.");
+ }
+
+ env.GOOGLECHAT_AUDIENCE = audience;
+ return audienceOutput(audience);
+ };
+}
+
+function requireOption(value: T | undefined, name: string): T {
+ if (value === undefined) {
+ throw new Error(`Google Chat tunnel/audience gate hook requires an injected ${name}.`);
+ }
+ return value;
+}
+
+export function createGooglechatTunnelAudienceGateHookRegistration(
+ options: GooglechatTunnelAudienceGateHookOptions = {},
+): MessagingHookRegistration {
+ return {
+ id: GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID,
+ handler: createGooglechatTunnelAudienceGateHook(options),
+ };
+}
diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.test.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.test.ts
new file mode 100644
index 0000000000..dd86cd47f4
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.test.ts
@@ -0,0 +1,103 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+import { createDefaultGooglechatTunnelGateOptions } from "./tunnel-runtime";
+
+describe("Google Chat tunnel runtime", () => {
+ it("targets a dedicated route-restricted proxy instead of the dashboard", async () => {
+ const pidDir = "/tmp/nemoclaw-services-test-googlechat";
+ const startAll = vi.fn(async () => undefined);
+ const stopCloudflared = vi.fn();
+ const stopGooglechatWebhookProxy = vi.fn();
+ const startGooglechatWebhookProxy = vi.fn(async () => 24680);
+ const services = {
+ getTunnelUrl: vi.fn(() => "https://restricted.trycloudflare.com"),
+ readCloudflaredState: vi.fn(() => ({ kind: "running", pid: 123 }) as const),
+ resolveServicePidDir: vi.fn(() => "/tmp/nemoclaw-services-test"),
+ startAll,
+ stopCloudflared,
+ };
+ const webhookProxy = {
+ readGooglechatWebhookProxyState: vi.fn(
+ () => ({ running: true, port: 24680, upstreamPort: 18789 }) as const,
+ ),
+ startGooglechatWebhookProxy,
+ stopGooglechatWebhookProxy,
+ };
+ const options = createDefaultGooglechatTunnelGateOptions({
+ dashboardPort: 18789,
+ loadServices: () => services,
+ loadWebhookProxy: () => webhookProxy,
+ });
+
+ expect(options.readTunnelState?.()).toEqual({ running: true });
+ await options.startTunnel?.();
+ expect(stopCloudflared).toHaveBeenCalledWith({ pidDir });
+ expect(startGooglechatWebhookProxy).toHaveBeenCalledWith(pidDir, 18789);
+ expect(startAll).toHaveBeenCalledWith({
+ pidDir,
+ dashboardPort: 24680,
+ cloudflareTunnelToken: "",
+ });
+ expect(options.getTunnelUrl?.()).toBe("https://restricted.trycloudflare.com");
+ expect(services.getTunnelUrl).toHaveBeenCalledWith(pidDir, 24680);
+
+ options.stopTunnel?.();
+ expect(stopCloudflared).toHaveBeenLastCalledWith({ pidDir });
+ expect(stopGooglechatWebhookProxy).toHaveBeenCalledWith(pidDir);
+ });
+
+ it("does not report the tunnel ready when its route proxy is unavailable", () => {
+ const options = createDefaultGooglechatTunnelGateOptions({
+ loadServices: () => ({
+ getTunnelUrl: () => "https://unsafe.example.com",
+ readCloudflaredState: () => ({ kind: "running", pid: 123 }),
+ resolveServicePidDir: () => "/tmp/nemoclaw-services-test",
+ startAll: async () => undefined,
+ stopCloudflared: () => undefined,
+ }),
+ loadWebhookProxy: () => ({
+ readGooglechatWebhookProxyState: () => ({
+ running: false,
+ port: null,
+ upstreamPort: null,
+ }),
+ startGooglechatWebhookProxy: async () => 24680,
+ stopGooglechatWebhookProxy: () => undefined,
+ }),
+ });
+
+ expect(options.readTunnelState?.()).toEqual({ running: false });
+ expect(options.getTunnelUrl?.()).toBe("");
+ });
+
+ it("stops the route proxy when cloudflared startup fails", async () => {
+ const stopGooglechatWebhookProxy = vi.fn();
+ const options = createDefaultGooglechatTunnelGateOptions({
+ loadServices: () => ({
+ getTunnelUrl: () => "",
+ readCloudflaredState: () => ({ kind: "stopped" }),
+ resolveServicePidDir: () => "/tmp/nemoclaw-services-test",
+ startAll: async () => {
+ throw new Error("cloudflared failed");
+ },
+ stopCloudflared: () => undefined,
+ }),
+ loadWebhookProxy: () => ({
+ readGooglechatWebhookProxyState: () => ({
+ running: false,
+ port: null,
+ upstreamPort: null,
+ }),
+ startGooglechatWebhookProxy: async () => 24680,
+ stopGooglechatWebhookProxy,
+ }),
+ });
+
+ await expect(options.startTunnel?.()).rejects.toThrow("cloudflared failed");
+ expect(stopGooglechatWebhookProxy).toHaveBeenCalledWith(
+ "/tmp/nemoclaw-services-test-googlechat",
+ );
+ });
+});
diff --git a/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts
new file mode 100644
index 0000000000..28f09d3aea
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts
@@ -0,0 +1,105 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { DASHBOARD_PORT } from "../../../../core/ports";
+import { googlechatWebhookTunnelPidDir } from "../../../../tunnel/googlechat-webhook-lifecycle";
+import type { GooglechatTunnelAudienceGateHookOptions } from "./tunnel-audience-gate";
+
+type TunnelServices = Pick<
+ typeof import("../../../../tunnel/services"),
+ "getTunnelUrl" | "readCloudflaredState" | "resolveServicePidDir" | "startAll" | "stopCloudflared"
+>;
+type WebhookProxy = Pick<
+ typeof import("../../../../tunnel/googlechat-webhook-proxy"),
+ "readGooglechatWebhookProxyState" | "startGooglechatWebhookProxy" | "stopGooglechatWebhookProxy"
+>;
+
+export interface GooglechatTunnelRuntimeDeps {
+ readonly dashboardPort?: number;
+ readonly hasCloudflared?: () => boolean;
+ readonly loadServices?: () => TunnelServices;
+ readonly loadWebhookProxy?: () => WebhookProxy;
+ readonly prompt?: (question: string) => Promise;
+}
+
+// Side-effectful defaults for the tunnel/audience gate, kept out of the hook
+// file itself. Google Chat uses a dedicated cloudflared state directory and a
+// loopback-only proxy that forwards POST /googlechat while denying dashboard
+// and control paths. It must not reuse `nemoclaw tunnel start`, whose purpose is
+// to publish the full dashboard.
+// tunnel/services, node:child_process, and credentials/store are lazy-required
+// inside the callbacks (not imported at the top) so they stay out of the
+// eagerly-imported hook graph: the built-in hook registry is constructed at
+// module load, and importing tunnel/services eagerly closes an import cycle.
+export function createDefaultGooglechatTunnelGateOptions(
+ deps: GooglechatTunnelRuntimeDeps = {},
+): GooglechatTunnelAudienceGateHookOptions {
+ const dashboardPort = deps.dashboardPort ?? DASHBOARD_PORT;
+ const loadServices =
+ deps.loadServices ?? (() => require("../../../../tunnel/services") as TunnelServices);
+ const loadWebhookProxy =
+ deps.loadWebhookProxy ??
+ (() => require("../../../../tunnel/googlechat-webhook-proxy") as WebhookProxy);
+ const resolveGooglechatPidDir = (): string =>
+ googlechatWebhookTunnelPidDir(loadServices().resolveServicePidDir());
+ return {
+ hasCloudflared:
+ deps.hasCloudflared ??
+ (() => {
+ try {
+ const { execSync } = require("node:child_process") as typeof import("node:child_process");
+ execSync("command -v cloudflared", { stdio: ["ignore", "ignore", "ignore"] });
+ return true;
+ } catch {
+ // Not found or unprobeable — `command -v` exits non-zero (execSync
+ // throws) when cloudflared is absent; either way, treat as absent and
+ // let the gate prompt the user to install it.
+ return false;
+ }
+ }),
+ readTunnelState: () => {
+ const { readCloudflaredState } = loadServices();
+ const { readGooglechatWebhookProxyState } = loadWebhookProxy();
+ const pidDir = resolveGooglechatPidDir();
+ return {
+ running:
+ readCloudflaredState(pidDir).kind === "running" &&
+ readGooglechatWebhookProxyState(pidDir).running,
+ };
+ },
+ startTunnel: async () => {
+ const { startAll, stopCloudflared } = loadServices();
+ const { startGooglechatWebhookProxy, stopGooglechatWebhookProxy } = loadWebhookProxy();
+ const pidDir = resolveGooglechatPidDir();
+ stopCloudflared({ pidDir });
+ const proxyPort = await startGooglechatWebhookProxy(pidDir, dashboardPort);
+ try {
+ await startAll({ pidDir, dashboardPort: proxyPort, cloudflareTunnelToken: "" });
+ } catch (error) {
+ stopGooglechatWebhookProxy(pidDir);
+ throw error;
+ }
+ },
+ stopTunnel: () => {
+ const { stopCloudflared } = loadServices();
+ const { stopGooglechatWebhookProxy } = loadWebhookProxy();
+ const pidDir = resolveGooglechatPidDir();
+ stopCloudflared({ pidDir });
+ stopGooglechatWebhookProxy(pidDir);
+ },
+ getTunnelUrl: () => {
+ const { getTunnelUrl: getServiceTunnelUrl } = loadServices();
+ const { readGooglechatWebhookProxyState } = loadWebhookProxy();
+ const pidDir = resolveGooglechatPidDir();
+ const proxy = readGooglechatWebhookProxyState(pidDir);
+ return proxy.running ? getServiceTunnelUrl(pidDir, proxy.port) : "";
+ },
+ prompt:
+ deps.prompt ??
+ ((question: string) => {
+ const { prompt } =
+ require("../../../../credentials/store") as typeof import("../../../../credentials/store");
+ return prompt(question);
+ }),
+ };
+}
diff --git a/src/lib/messaging/channels/googlechat/manifest.ts b/src/lib/messaging/channels/googlechat/manifest.ts
new file mode 100644
index 0000000000..03d9e06c0c
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/manifest.ts
@@ -0,0 +1,312 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { ChannelManifest } from "../../manifest";
+
+// Google Chat is an inbound-webhook channel. Unlike Microsoft Teams (which runs
+// its own bot web server on a separate port and needs a host forward), the
+// Google Chat webhook is served by the OpenClaw gateway on the shared dashboard
+// port (18789) at `/googlechat`. There is no `hostForward`; the tunnel/audience
+// enroll hook publishes only that route through a dedicated loopback proxy and
+// cloudflared process rather than exposing the full dashboard origin.
+export const googlechatManifest = {
+ schemaVersion: 1,
+ id: "googlechat",
+ displayName: "Google Chat",
+ description: "Google Chat (Chat API) bot messaging",
+ enrollmentNotes: [
+ "Google Workspace accounts need no appPrincipal — leave it blank.",
+ "──────────────────────────────────────────────────────────────",
+ "Google Chat — capture appPrincipal (personal / standalone Gmail only)",
+ " Workspace accounts: skip this, you are done.",
+ " Personal/standalone accounts are served as Workspace add-ons and need",
+ " channels.googlechat.appPrincipal (the add-on's ~21-digit numeric ID, not",
+ " an email). It is stable for a given Google account/add-on: if you",
+ " already know it (e.g. captured on an earlier build), paste it at the",
+ " appPrincipal prompt and skip the steps below — you never need to capture",
+ " it twice. Otherwise leave it blank and a discovery placeholder is seeded",
+ " so the first message reveals the real value.",
+ " These steps are only needed the first time, when you do not yet know your",
+ " appPrincipal — after onboarding finishes and the sandbox is live:",
+ " 1. Watch the gateway log:",
+ ' nemoclaw logs --follow | grep "unexpected add-on principal"',
+ " 2. Send ONE direct message to the bot. It will NOT reply yet (expected).",
+ " The log prints: unexpected add-on principal: ",
+ " That is your appPrincipal.",
+ " 3. Persist it and rebuild:",
+ " GOOGLECHAT_APP_PRINCIPAL= nemoclaw channels add googlechat",
+ " nemoclaw rebuild --yes",
+ "──────────────────────────────────────────────────────────────",
+ "The dedicated public endpoint forwards only POST /googlechat; open the Control UI from http://127.0.0.1:18789 (localhost), not the webhook URL.",
+ ],
+ supportedAgents: ["openclaw"],
+ auth: {
+ mode: "token-paste",
+ },
+ inputs: [
+ {
+ id: "serviceAccount",
+ kind: "secret",
+ required: true,
+ envKey: "GOOGLECHAT_SERVICE_ACCOUNT",
+ prompt: {
+ label: "Google Chat service account JSON",
+ help: "Paste the downloaded service account JSON key as a single line (minified). Google Cloud Console → IAM → Service Accounts → Keys → Add key → JSON.",
+ },
+ },
+ {
+ id: "audienceType",
+ kind: "config",
+ required: false,
+ envKey: "GOOGLECHAT_AUDIENCE_TYPE",
+ statePath: "googlechatConfig.audienceType",
+ validValues: ["app-url", "project-number"],
+ defaultValue: "app-url",
+ },
+ {
+ id: "audience",
+ kind: "config",
+ required: false,
+ envKey: "GOOGLECHAT_AUDIENCE",
+ statePath: "googlechatConfig.audience",
+ prompt: {
+ label: "Google Chat webhook audience",
+ help: "Usually filled automatically from the public tunnel URL. For audienceType 'project-number', enter your GCP project number instead.",
+ emptyValueMessage: "inbound webhook verification will be unconfigured",
+ },
+ },
+ {
+ id: "appPrincipal",
+ kind: "config",
+ required: false,
+ envKey: "GOOGLECHAT_APP_PRINCIPAL",
+ statePath: "googlechatConfig.appPrincipal",
+ formatPattern: "^[0-9]{6,32}$",
+ formatHint:
+ "appPrincipal is the add-on's numeric OAuth client ID (uniqueId, ~21 digits), not an email.",
+ prompt: {
+ label: "Google Chat appPrincipal (personal/standalone accounts only)",
+ help: "Leave blank for Google Workspace accounts. For personal/standalone Google accounts, paste the add-on's 21-digit numeric ID if you already have it; otherwise leave blank and capture it after the bot is live.",
+ emptyValueMessage: "Workspace accounts do not need it; personal accounts must set it later",
+ },
+ },
+ {
+ id: "allowFrom",
+ kind: "config",
+ required: false,
+ envKey: "GOOGLECHAT_ALLOWED_USERS",
+ statePath: "allowedIds.googlechat",
+ prompt: {
+ label: "Google Chat DM allowlist (comma-separated user IDs)",
+ help: "Optional: restrict who can DM the bot. Enter Google Chat user IDs (users/NNN) — NOT emails: the bot matches IDs only by default, so an email entry is ignored. Leave blank to require pairing (recommended).",
+ emptyValueMessage: "bot will require manual pairing",
+ },
+ },
+ ],
+ // Outbound auth is gateway-minted: the OpenShell `google-service-account-jwt`
+ // refresh provider mints the Google Chat bot token from the pasted service
+ // account, and the L7 proxy injects it as `Authorization: Bearer` on
+ // chat.googleapis.com. The service-account private key stays gateway-side and
+ // never enters the sandbox. The bridge provider + refresh are wired in
+ // src/lib/onboard/googlechat-bridge-provider.ts; the googlechat-outbound-auth
+ // runtime preload makes the plugin send the injected bearer instead of signing
+ // in-process. No credentials/secretFiles here — the pasted serviceAccount is
+ // consumed only as gateway-side refresh material, never delivered into the sandbox.
+ // (The `serviceAccountFile` in `render` below is a start-gate marker only, not a
+ // delivered file — see the comment there.)
+ credentials: [],
+ policyPresets: [{ name: "googlechat", policyKeys: ["googlechat"] }],
+ render: [
+ {
+ id: "googlechat-openclaw-channel",
+ kind: "json-fragment",
+ agent: "openclaw",
+ target: "openclaw.json",
+ fragment: {
+ path: "channels.googlechat",
+ value: {
+ enabled: true,
+ // Start-gate SENTINEL — a deliberately synthetic, non-existent path, NOT a
+ // real credential location. OpenClaw's channel-start gate only requires some
+ // serviceAccount* to be set (isConfigured: credentialSource !== "none") to
+ // start the webhook; it accepts any non-empty string here and does not read
+ // the file at start. The token is gateway-minted and proxy-injected, and the
+ // googlechat-outbound-auth preload short-circuits the token producer before
+ // this path could be read, so no service-account key is ever delivered into
+ // the sandbox. (Clean fix is upstream: a non-SA "configured"/accessToken
+ // credential source in @openclaw/googlechat — tracked follow-up.)
+ serviceAccountFile: "/nonexistent/googlechat-gateway-minted-no-service-account-file",
+ audienceType: "{{googlechatConfig.audienceType}}",
+ audience: "{{googlechatConfig.audience}}",
+ appPrincipal: "{{googlechatConfig.appPrincipal}}",
+ webhookPath: "/googlechat",
+ healthMonitor: {
+ enabled: false,
+ },
+ dm: {
+ policy: "{{allowedIds.googlechat.dmPolicy}}",
+ allowFrom: "{{allowedIds.googlechat.values}}",
+ },
+ },
+ },
+ },
+ {
+ id: "googlechat-openclaw-plugin",
+ kind: "json-fragment",
+ agent: "openclaw",
+ target: "openclaw.json",
+ fragment: {
+ path: "plugins.entries.googlechat",
+ value: {
+ enabled: true,
+ },
+ },
+ },
+ {
+ // ── Workaround Analysis ──
+ // 1. What: render gateway.reload.mode=off into the sandbox's openclaw.json.
+ // 2. Why: ~60s after boot OpenClaw rewrites its OWN config (adds default
+ // provider-plugin entries); with hot-reload ON it reloads plugins,
+ // rebuilds the HTTP route table and DROPS the Google Chat webhook
+ // route → inbound 404s, bot goes silent ~60s after every start.
+ // 3. Alts: none in-sandbox — the self-write is OpenClaw's; a periodic restart
+ // only resets the timer. Real fix is upstream (5).
+ // 4. Risk: low — the sandbox openclaw.json is build-time-sealed (0600 +
+ // integrity hash), so nothing legitimately reloads it at runtime;
+ // NemoClaw still restarts the gateway explicitly on rebuild/restart.
+ // 5. Exit: upstream reload re-mounts channels (not just plugins) on config
+ // reload → drop this fragment.
+ id: "googlechat-openclaw-gateway-reload-off",
+ kind: "json-fragment",
+ agent: "openclaw",
+ target: "openclaw.json",
+ fragment: {
+ path: "gateway.reload",
+ value: {
+ mode: "off",
+ },
+ },
+ },
+ ],
+ runtime: {
+ openclaw: {
+ channelName: "googlechat",
+ visibility: {
+ configKeys: ["googlechat"],
+ logPatterns: ["googlechat"],
+ },
+ // Interim sandbox-DNS workaround: the sandbox netns is DNS-less (all
+ // resolution goes through the L7 proxy), but OpenClaw's Google Chat fetches
+ // default to STRICT SSRF mode, which does a LOCAL getaddrinfo first and
+ // fails with EAI_AGAIN. This boot preload rewrites the plugin's googleapis
+ // fetches (inbound cert verify + all outbound sends) to the guard's
+ // first-class `trusted_env_proxy` mode, so they skip the local resolve and
+ // route by hostname through the L7 proxy (which resolves + enforces policy).
+ // No sentinel IP. It replaces the older googlechat-dns-resolve.ts sentinel
+ // shim, and is exactly the upstream OpenClaw fix (trusted-env-proxy fetch,
+ // like web_fetch openclaw#50650) applied in the plugin bundle; remove once
+ // that lands upstream. See runtime/googlechat-trusted-proxy-fetch.ts.
+ //
+ // Second boot preload: move OUTBOUND auth off the in-sandbox SA key. By
+ // default @openclaw/googlechat signs an auth JWT with the SA private key
+ // in-process, which forces the key to live in the sandbox. This preload
+ // rewrites the plugin's single token producer to return the OpenShell
+ // gateway-minted credential placeholder (GOOGLE_CHAT_ACCESS_TOKEN) so the
+ // L7 proxy injects the real bearer outbound and the key never enters the
+ // sandbox. See runtime/googlechat-outbound-auth.ts.
+ nodePreloads: [
+ {
+ module: "googlechat-trusted-proxy-fetch",
+ injectInto: ["boot"],
+ optional: false,
+ installMessage:
+ "[channels] Installing Google Chat trusted-proxy-fetch patch (route googleapis via trusted env proxy)",
+ installedMessage:
+ "[channels] Google Chat trusted-proxy-fetch patch installed (NODE_OPTIONS updated)",
+ },
+ {
+ module: "googlechat-outbound-auth",
+ injectInto: ["boot"],
+ optional: false,
+ installMessage:
+ "[channels] Installing Google Chat outbound-auth patch (gateway-minted bearer)",
+ installedMessage:
+ "[channels] Google Chat outbound-auth patch installed (NODE_OPTIONS updated)",
+ },
+ ],
+ secretScans: [
+ {
+ path: "/sandbox/.openclaw/openclaw.json",
+ pattern: "-----BEGIN (?:RSA )?PRIVATE KEY-----",
+ message:
+ "[SECURITY] Google Chat service account private key leaked into {path} - refusing to serve",
+ exitCode: 78,
+ },
+ ],
+ },
+ },
+ agentPackages: [
+ {
+ id: "openclawPluginPackage",
+ agent: "openclaw",
+ manager: "openclaw-plugin",
+ spec: "npm:@openclaw/googlechat@{{openclaw.version}}",
+ pin: true,
+ integrityByVersion: {
+ "2026.6.10":
+ "sha512-3ay7CEcNs2BKj4Y3IkqLIp2B+8RXRmWDFFGPkWk3qSjHRl3rsROpBFUDBbJI+33SzNsAubWum7mOo08by5i7LQ==",
+ },
+ tarballUrlByVersion: {
+ "2026.6.10": "https://registry.npmjs.org/@openclaw/googlechat/-/googlechat-2026.6.10.tgz",
+ },
+ required: true,
+ },
+ ],
+ hooks: [
+ {
+ id: "googlechat-tunnel-audience-gate",
+ phase: "enroll",
+ handler: "googlechat.tunnelAudienceGate",
+ inputs: ["audienceType", "audience"],
+ outputs: [
+ {
+ id: "audience",
+ kind: "config",
+ },
+ ],
+ onFailure: "skip-channel",
+ },
+ {
+ id: "googlechat-service-account",
+ phase: "enroll",
+ handler: "common.tokenPaste",
+ outputs: [
+ {
+ id: "serviceAccount",
+ kind: "secret",
+ required: true,
+ },
+ ],
+ onFailure: "skip-channel",
+ },
+ {
+ id: "googlechat-config-prompt",
+ phase: "enroll",
+ handler: "common.configPrompt",
+ outputs: [
+ {
+ id: "audience",
+ kind: "config",
+ },
+ {
+ id: "appPrincipal",
+ kind: "config",
+ },
+ {
+ id: "allowFrom",
+ kind: "config",
+ },
+ ],
+ },
+ ],
+} as const satisfies ChannelManifest;
diff --git a/src/lib/messaging/channels/googlechat/policy/openclaw.yaml b/src/lib/messaging/channels/googlechat/policy/openclaw.yaml
new file mode 100644
index 0000000000..e4d08d2c68
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/policy/openclaw.yaml
@@ -0,0 +1,46 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+preset:
+ name: googlechat
+ description: "Google Chat (Chat API) message send and inbound JWT verification certs; the outbound token is minted gateway-side, never in the sandbox"
+
+network_policies:
+ googlechat:
+ name: googlechat
+ endpoints:
+ # Chat REST API: send/update/delete messages and read spaces.
+ - host: chat.googleapis.com
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ request_body_credential_rewrite: true
+ # Write methods are scoped to the Chat REST `spaces` tree (not "/**") so
+ # the bot can only send/edit/delete within Chat spaces. Mutations are
+ # POST/PATCH/DELETE — the Chat API defines no PUT for these resources.
+ rules:
+ # Reads: spaces, messages, members across the Chat REST v1 tree.
+ - allow: { method: GET, path: "/v1/**" }
+ # Send a message: POST /v1/spaces/{space}/messages.
+ - allow: { method: POST, path: "/v1/spaces/**" }
+ # PATCH is how OpenClaw edits a message in place while streaming a
+ # reply (spaces.messages.patch): it POSTs a placeholder ("… is
+ # typing"), then PATCHes that message as tokens arrive. Without this
+ # the edit is policy-denied (403), the placeholder is never replaced,
+ # and the final text lands as a separate message.
+ - allow: { method: PATCH, path: "/v1/spaces/**" }
+ # Delete a message: DELETE /v1/spaces/{space}/messages/{id}.
+ - allow: { method: DELETE, path: "/v1/spaces/**" }
+ # Inbound JWT verification certs: public keys, GET-only, no credential
+ # rewrite. Broad GET /** (not pinned) because google-auth-library owns the
+ # cert paths and switches URL/format by version — pinning risks breaking
+ # inbound verification on a plugin bump. Matches other presets' /**.
+ - host: www.googleapis.com
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ rules:
+ - allow: { method: GET, path: "/**" }
+ binaries:
+ - { path: /usr/local/bin/node }
+ - { path: /usr/bin/node }
diff --git a/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml b/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml
new file mode 100644
index 0000000000..5509e4d640
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml
@@ -0,0 +1,58 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Google Chat outbound-auth bridge provider profile.
+#
+# Lets the OpenShell gateway MINT the Google Chat bot access token from the
+# service-account key (ProviderCredentialRefreshStrategy google_service_account_jwt)
+# and inject it as `Authorization: Bearer ` on outbound chat.googleapis.com
+# requests. The service-account private key stays gateway-side; the sandbox only
+# ever sees the `openshell:resolve:env:GOOGLE_CHAT_ACCESS_TOKEN` placeholder, which
+# the L7 proxy rewrites to the minted token.
+#
+# Paired with the googlechat-outbound-auth runtime preload, which rewrites the
+# plugin's token producer to return that placeholder instead of signing in-process.
+#
+# The refresh material VALUES are supplied at onboard/rebuild time by
+# src/lib/onboard/messaging-bridge-provider.ts. Non-secret values use `--material`;
+# the private key uses `--secret-material-env` so it never appears in argv. The
+# block below declares their shape so the gateway knows which keys are secret.
+id: google-chat-bridge
+display_name: Google Chat Bridge
+description: Gateway-minted Google Chat bot token (service-account JWT) for outbound chat.googleapis.com
+category: agent
+credentials:
+ - name: access_token
+ description: Google Chat bot OAuth access token (gateway-minted via service-account JWT)
+ env_vars:
+ - GOOGLE_CHAT_ACCESS_TOKEN
+ required: true
+ auth_style: bearer
+ header_name: Authorization
+ query_param: ''
+ refresh:
+ strategy: google-service-account-jwt
+ scopes:
+ - https://www.googleapis.com/auth/chat.bot
+ material:
+ - name: client_email
+ description: Service-account client email (JWT issuer)
+ required: true
+ - name: private_key
+ description: Service-account RSA private key (PEM); signs the JWT assertion
+ required: true
+ secret: true
+ - name: scope
+ description: OAuth scope(s) to mint the token for
+endpoints:
+ - host: chat.googleapis.com
+ port: 443
+ protocol: rest
+ access: read-write
+ enforcement: enforce
+binaries:
+ - /usr/local/bin/node
+ - /usr/bin/node
+ - /usr/local/bin/curl
+ - /usr/bin/curl
+inference_capable: false
diff --git a/src/lib/messaging/channels/googlechat/rendered-config-parser.ts b/src/lib/messaging/channels/googlechat/rendered-config-parser.ts
new file mode 100644
index 0000000000..e78018ed31
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/rendered-config-parser.ts
@@ -0,0 +1,41 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ getStructuredConfigValue,
+ type RenderedChannelConfigParser,
+ structuredConfigKey,
+} from "../rendered-config-parser-utils";
+
+// Google Chat is OpenClaw-only; all operator-facing config renders under
+// `channels.googlechat` in openclaw.json. The `serviceAccountFile` is a start-gate
+// sentinel (not user config) and the outbound bearer is gateway-minted, so neither
+// is surfaced here.
+export const googlechatRenderedConfigParser: RenderedChannelConfigParser = {
+ listConfigVisibilityKeys(context) {
+ if (context.agentId !== "openclaw") return [];
+ return [
+ structuredConfigKey("audienceType", "openclaw.json", [
+ "channels",
+ "googlechat",
+ "audienceType",
+ ]),
+ structuredConfigKey("audience", "openclaw.json", ["channels", "googlechat", "audience"]),
+ structuredConfigKey("appPrincipal", "openclaw.json", [
+ "channels",
+ "googlechat",
+ "appPrincipal",
+ ]),
+ structuredConfigKey("allowFrom", "openclaw.json", [
+ "channels",
+ "googlechat",
+ "dm",
+ "allowFrom",
+ ]),
+ ];
+ },
+
+ getValue(key, source) {
+ return getStructuredConfigValue(source, key.path);
+ },
+};
diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts
new file mode 100644
index 0000000000..5dfc170515
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.test.ts
@@ -0,0 +1,127 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { outboundAuthPatchInternals } from "./googlechat-outbound-auth";
+
+// The self-installing preload publishes its pure helpers here on require (above).
+const { patchSource, buildShortCircuit, isPatchError, isOpenClawGooglechatFile } =
+ outboundAuthPatchInternals as {
+ patchSource: (source: string, filename: string) => string;
+ buildShortCircuit: () => string;
+ isPatchError: (reason: unknown) => boolean;
+ isOpenClawGooglechatFile: (filename: string) => boolean;
+ };
+
+const FILE = "/x/node_modules/@openclaw/googlechat/dist/auth.js";
+const CALL_MARKER = "nemoclaw: googlechat outbound bearer via gateway-minted credential";
+const CANONICAL = "openshell:resolve:env:GOOGLE_CHAT_ACCESS_TOKEN";
+// A representative slice of the plugin's token producer.
+const PLUGIN_SRC =
+ "async function getGoogleChatAccessToken(account) {\n" +
+ " const client = await auth.getClient();\n" +
+ " return (await client.getAccessToken()).token;\n" +
+ "}";
+
+describe("googlechat outbound-auth patch", () => {
+ it("matches the external-extension install path, not only the package path", () => {
+ // Bundled/package load path (pre-2026.6.10).
+ expect(isOpenClawGooglechatFile("/x/node_modules/@openclaw/googlechat/dist/auth.js")).toBe(
+ true,
+ );
+ // External-extension install path (openclaw plugins install → 2026.6.10 sandbox).
+ expect(isOpenClawGooglechatFile("/sandbox/.openclaw/extensions/googlechat/dist/auth.js")).toBe(
+ true,
+ );
+ // Other channels and non-.js files stay untouched.
+ expect(isOpenClawGooglechatFile("/sandbox/.openclaw/extensions/slack/dist/index.js")).toBe(
+ false,
+ );
+ expect(isOpenClawGooglechatFile("/x/extensions/googlechat/dist/index.ts")).toBe(false);
+ });
+
+ it("rewrites the token producer when the anchor matches", () => {
+ const patched = patchSource(PLUGIN_SRC, FILE);
+ expect(patched).not.toBe(PLUGIN_SRC);
+ expect(patched).toContain(CALL_MARKER);
+ // The short-circuit is injected at the TOP of the body; the original body is
+ // left in place (now unreachable) rather than deleted.
+ expect(patched).toContain("auth.getClient()");
+ expect(patched.indexOf(CALL_MARKER)).toBeLessThan(patched.indexOf("auth.getClient()"));
+ });
+
+ it("is idempotent — already-patched source is returned unchanged", () => {
+ const once = patchSource(PLUGIN_SRC, FILE);
+ expect(patchSource(once, FILE)).toBe(once);
+ });
+
+ it("passes through files that do not define the producer", () => {
+ const other = "export function somethingElse() {\n return 1;\n}";
+ expect(patchSource(other, FILE)).toBe(other);
+ });
+
+ it("throws a named patch error when the definition drifts", () => {
+ // Contains the definition substring but not the expected callable shape.
+ const drift = "// references function getGoogleChatAccessToken in a comment only";
+ expect(() => patchSource(drift, FILE)).toThrow(/shape not recognized/);
+ let caught: unknown;
+ try {
+ patchSource(drift, FILE);
+ } catch (error) {
+ caught = error;
+ }
+ expect(isPatchError(caught)).toBe(true);
+ });
+
+ it("isPatchError distinguishes drift errors from unrelated ones", () => {
+ expect(isPatchError(new Error("some unrelated failure"))).toBe(false);
+ expect(
+ isPatchError(
+ new Error(
+ "OpenClaw Google Chat getGoogleChatAccessToken definition shape not recognized in x.js",
+ ),
+ ),
+ ).toBe(true);
+ });
+
+ describe("injected short-circuit runtime behavior", () => {
+ const ENV = "GOOGLE_CHAT_ACCESS_TOKEN";
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ // Build a callable from the patched producer whose original body returns a
+ // sentinel, so we can prove the injected guard runs before (and instead of) it.
+ function buildProducer(): () => string {
+ const patched = patchSource(
+ 'function getGoogleChatAccessToken(account) { return "IN_PROCESS_MINT"; }',
+ FILE,
+ );
+ return new Function(`${patched}\n return getGoogleChatAccessToken;`)() as () => string;
+ }
+
+ it("returns the revision-less canonical placeholder when a stamped placeholder is set", () => {
+ vi.stubEnv(ENV, "openshell:resolve:env:v7_GOOGLE_CHAT_ACCESS_TOKEN");
+ expect(buildProducer()()).toBe(CANONICAL);
+ });
+
+ it("returns a raw (non-placeholder) env value as-is for manual deployments", () => {
+ vi.stubEnv(ENV, "ya29.raw-access-token");
+ expect(buildProducer()()).toBe("ya29.raw-access-token");
+ });
+
+ it("throws (never reaches the in-process body) when the env is unset", () => {
+ vi.stubEnv(ENV, undefined);
+ const producer = buildProducer();
+ expect(producer).toThrow(/GOOGLE_CHAT_ACCESS_TOKEN is not set/);
+ });
+ });
+
+ it("buildShortCircuit emits the canonical placeholder and the fail-closed throw", () => {
+ const src = buildShortCircuit();
+ expect(src).toContain(`"${CANONICAL}"`);
+ expect(src).toContain("throw new Error");
+ expect(src).toContain(CALL_MARKER);
+ });
+});
diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts
new file mode 100644
index 0000000000..c0bd2a552a
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-outbound-auth.ts
@@ -0,0 +1,311 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+// googlechat-outbound-auth.ts — load-time patch that moves Google Chat OUTBOUND
+// authentication off the in-sandbox service-account key and onto a
+// gateway-minted token. Boot-injected into the OpenClaw gateway via
+// runtime.nodePreloads, gated to when the googlechat channel is active.
+//
+// ── Workaround Analysis ──────────────────────────────────────────────────────
+// 1. What: rewrites the plugin's single outbound-token producer
+// (getGoogleChatAccessToken) to return the OpenShell placeholder instead
+// of signing a JWT in-process; the proxy swaps it for the real token.
+// 2. Why: stock @openclaw/googlechat mints its token in-process from the SA
+// private key, forcing the key into the sandbox — against the
+// key-out-of-sandbox model. Gateway-side minting keeps the key out.
+// 3. Alts: no native pre-minted-token auth mode upstream yet (5); config injection
+// rejected — the plugin schema is .strict() with no accessToken field.
+// 4. Risk: version-sensitive (bundle anchor) but bounded — integrity-version-pinned
+// plugin, anchors unit-tested; drift AND unset-env both fail loud (throw),
+// so a misconfigured channel never silently sends unauthenticated.
+// 5. Exit: upstream native accessToken/accessTokenRef auth mode — see below.
+//
+// ── The story: what this changes, and why ────────────────────────────────────
+// Out of the box @openclaw/googlechat mints its own OAuth token IN-PROCESS: it
+// RS256-signs a JWT assertion with the service-account (SA) PRIVATE KEY and
+// exchanges it at oauth2.googleapis.com for an access token. That requires the SA
+// private key to live inside the sandbox, which deviates from NemoClaw's security
+// model — secrets should never sit in the sandbox; the L7 egress proxy
+// materializes them only on outbound requests.
+//
+// OpenShell can instead mint the Google access token GATEWAY-SIDE from the SA
+// key (ProviderCredentialRefreshStrategy google_service_account_jwt) and inject
+// it onto outbound chat.googleapis.com requests via the standard credential
+// rewrite. In that model the sandbox only ever sees the placeholder
+// `openshell:resolve:env:GOOGLE_CHAT_ACCESS_TOKEN`, never the key.
+//
+// The single producer of the outbound bearer in the plugin is
+// `getGoogleChatAccessToken(account)` (auth.ts), funnelled through the one
+// request wrapper that stamps `Authorization: Bearer ` on every
+// chat.googleapis.com call. This preload rewrites that producer at module load
+// so it returns the OpenShell-provided credential env value (the placeholder)
+// instead of constructing a google-auth client and signing locally. The proxy
+// then rewrites the placeholder in the Authorization header to the real minted
+// token — the same outbound-placeholder path every other channel uses.
+//
+// INBOUND webhook JWT verification is untouched: it uses Google's PUBLIC certs
+// + appPrincipal (no SA material) and a different code path, so it is unaffected
+// by this patch (its cert fetch is handled by the separate
+// googlechat-trusted-proxy-fetch patch).
+//
+// ── Contract with the B-side wiring ──────────────────────────────────────────
+// The OpenShell provider's injectable credential key MUST be
+// `GOOGLE_CHAT_ACCESS_TOKEN`. When the provider is wired the gateway env carries
+// a REVISION-STAMPED placeholder `openshell:resolve:env:vNNN_GOOGLE_CHAT_ACCESS_TOKEN`.
+// This patch detects the credential is wired (env set) and returns the REVISION-LESS
+// alias `openshell:resolve:env:GOOGLE_CHAT_ACCESS_TOKEN` so the proxy always resolves
+// to the LATEST refreshed token — the revision-stamped form pins to the boot token,
+// which expires (~1h) and is not refreshed in a long-running process. When the env is
+// UNSET (the bridge provider was not wired) the patch THROWS, so a misconfigured
+// active channel fails loudly at send time instead of silently.
+//
+// ── Long-term fix (remove this once it lands) ─────────────────────────────────
+// The clean fix is upstream in OpenClaw: a native pre-minted-token auth mode
+// (e.g. `accessToken`/`accessTokenRef`) on @openclaw/googlechat that skips
+// in-process minting and sends the bearer directly. When that ships, drop this
+// preload module and its `runtime.openclaw.nodePreloads` entry in the manifest.
+//
+// Mechanism mirrors slack-channel-guard.ts (load-time source rewrite of an
+// @openclaw/* dist module via the module loader hooks).
+
+// Test seam: the self-installing IIFE below publishes its pure source-rewrite
+// helpers here so unit tests can exercise patch / shape-drift / short-circuit
+// behavior directly. Requiring this module still installs the loader hooks, but
+// that install is inert for files outside @openclaw/googlechat.
+type CommonJsModuleLike = {
+ _compile?: (source: unknown, filename?: string) => unknown;
+};
+
+type CommonJsLoader = (
+ this: unknown,
+ mod: CommonJsModuleLike,
+ filename: string,
+ ...args: unknown[]
+) => unknown;
+
+type ModuleLoadResult = Record & { source?: unknown };
+type NextModuleLoad = (urlValue: string, context: unknown) => ModuleLoadResult;
+
+type OutboundAuthPatchInternals = {
+ patchSource: (source: string, filename: string) => string;
+ buildShortCircuit: () => string;
+ isPatchError: (reason: unknown) => boolean;
+ isOpenClawGooglechatFile: (filename: unknown) => boolean;
+ createComposableJsLoader: (loader: CommonJsLoader) => CommonJsLoader;
+};
+
+export const outboundAuthPatchInternals = {} as OutboundAuthPatchInternals;
+
+(function () {
+ "use strict";
+
+ // The injectable OpenShell credential key (and sandbox env var) that carries
+ // the outbound bearer placeholder. Co-designed with the B-side provider:
+ // `provider refresh configure --credential-key GOOGLE_CHAT_ACCESS_TOKEN`.
+ var ENV_VAR = "GOOGLE_CHAT_ACCESS_TOKEN";
+
+ // Idempotency / shape markers.
+ var CALL_MARKER = "nemoclaw: googlechat outbound bearer via gateway-minted credential";
+ var DEF_SIGNATURE = "function getGoogleChatAccessToken";
+
+ var processWithPatchMarker = process as NodeJS.Process & {
+ __nemoclawGooglechatOutboundAuthInstalled?: boolean;
+ };
+ if (processWithPatchMarker.__nemoclawGooglechatOutboundAuthInstalled) return;
+ try {
+ Object.defineProperty(processWithPatchMarker, "__nemoclawGooglechatOutboundAuthInstalled", {
+ value: true,
+ });
+ } catch (_e) {
+ processWithPatchMarker.__nemoclawGooglechatOutboundAuthInstalled = true;
+ }
+
+ function isOpenClawGooglechatFile(filename: unknown): boolean {
+ var normalized = String(filename || "").replace(/\\/g, "/");
+ if (!normalized.endsWith(".js")) return false;
+ // The plugin loads either from a package path (/@openclaw/googlechat/) or, when
+ // installed as an external extension (openclaw plugins install), flat from
+ // ~/.openclaw/extensions/googlechat/dist/. Match both; the getGoogleChatAccessToken
+ // shape gate below still confines the rewrite to the auth chunk.
+ return (
+ normalized.indexOf("/@openclaw/googlechat/") !== -1 ||
+ normalized.indexOf("/extensions/googlechat/") !== -1
+ );
+ }
+
+ // The guard injected at the top of getGoogleChatAccessToken's body. When the
+ // OpenShell-provided credential is present (the env var is set), return the
+ // REVISION-LESS placeholder `openshell:resolve:env:` — NOT the env var's
+ // own value. The sandbox env holds a *revision-stamped* placeholder
+ // (`openshell:resolve:env:vNNN_`) that the proxy pins to the BOOT token;
+ // that token expires (~1h, Google SA tokens) and is NOT refreshed inside a
+ // long-running gateway process, so returning it directly makes outbound replies
+ // die after the first token lifetime ("credential is expired"). The revision-less
+ // alias always resolves to the LATEST refreshed token (gateway re-mints on
+ // schedule); a raw (non-placeholder) env value is returned as-is for manual
+ // non-OpenShell deployments. When the env is UNSET the guard THROWS (the outbound
+ // bearer must come from the gateway credential). Built as a single line (no
+ // template-literal escaping) for a clean source rewrite.
+ function buildBearerShortCircuitSource(): string {
+ var canonical = "openshell:resolve:env:" + ENV_VAR;
+ return (
+ 'var __nemoGcRaw = (typeof process !== "undefined" && process.env) ' +
+ "? process.env." +
+ ENV_VAR +
+ ' : void 0; if (typeof __nemoGcRaw === "string" && __nemoGcRaw.length > 0) { ' +
+ 'return __nemoGcRaw.indexOf("openshell:resolve:env:") === 0 ? "' +
+ canonical +
+ '" : __nemoGcRaw; } throw new Error("nemoclaw googlechat: ' +
+ ENV_VAR +
+ ' is not set; the gateway-minted outbound bearer is unavailable"); /* ' +
+ CALL_MARKER +
+ " */"
+ );
+ }
+
+ function patchGooglechatOutboundAuthSource(source: string, filename: string): string {
+ // Only the dist chunk that DEFINES the producer is a patch target; files that
+ // merely call/import it (substring without the `function` keyword) pass through.
+ if (source.indexOf(DEF_SIGNATURE) === -1) return source;
+ // Already patched (idempotent across repeated --require of this preload).
+ if (source.indexOf(CALL_MARKER) !== -1) return source;
+
+ var anchor = /((?:async\s+)?function getGoogleChatAccessToken\s*\(([^)]*)\)\s*\{)/;
+ if (!anchor.test(source)) {
+ // The definition substring is present but not in the expected shape — the
+ // bundled plugin drifted. Fail loud (named patch error) rather than silently
+ // leaving outbound auth unpatched.
+ throw new Error(
+ "OpenClaw Google Chat getGoogleChatAccessToken definition shape not recognized in " +
+ filename +
+ "; outbound-auth patch not applied",
+ );
+ }
+ return source.replace(anchor, "$1\n " + buildBearerShortCircuitSource());
+ }
+
+ function errorMessage(reason: unknown): string {
+ if (typeof reason === "object" && reason !== null && "message" in reason) {
+ return String(Reflect.get(reason, "message") ?? "");
+ }
+ return String(reason ?? "");
+ }
+
+ function isGooglechatOutboundAuthPatchError(reason: unknown): boolean {
+ var msg = errorMessage(reason);
+ return (
+ msg.indexOf("OpenClaw Google Chat getGoogleChatAccessToken") !== -1 &&
+ msg.indexOf("shape not recognized") !== -1
+ );
+ }
+
+ function fileNameFromModuleUrl(urlValue: unknown): string {
+ if (typeof urlValue !== "string" || !urlValue.startsWith("file:")) return "";
+ try {
+ return require("url").fileURLToPath(urlValue);
+ } catch (_e) {
+ return "";
+ }
+ }
+
+ function sourceToText(source: unknown): string | null {
+ if (typeof source === "string") return source;
+ if (typeof Buffer !== "undefined") {
+ if (Buffer.isBuffer(source)) return source.toString("utf8");
+ if (source instanceof Uint8Array) return Buffer.from(source).toString("utf8");
+ if (source instanceof ArrayBuffer) return Buffer.from(source).toString("utf8");
+ }
+ return null;
+ }
+
+ // Compose with other CommonJS source-rewrite preloads by intercepting
+ // Module._compile instead of reading + compiling the file directly. Calling the
+ // previous loader lets each wrapper transform the same source in sequence,
+ // regardless of NODE_OPTIONS preload order.
+ function createComposableJsLoader(originalJsLoader: CommonJsLoader): CommonJsLoader {
+ return function nemoclawGooglechatJsLoader(
+ this: unknown,
+ mod: CommonJsModuleLike,
+ filename: string,
+ ...args: unknown[]
+ ) {
+ if (!isOpenClawGooglechatFile(filename) || typeof mod._compile !== "function") {
+ return originalJsLoader.call(this, mod, filename, ...args);
+ }
+
+ var originalCompile = mod._compile;
+ mod._compile = function nemoclawGooglechatCompile(
+ this: unknown,
+ source: unknown,
+ loadedFilename?: string,
+ ) {
+ var sourceText = sourceToText(source);
+ var patched =
+ sourceText === null
+ ? source
+ : patchGooglechatOutboundAuthSource(sourceText, loadedFilename || filename);
+ return originalCompile.call(this, patched, loadedFilename);
+ };
+ try {
+ return originalJsLoader.call(this, mod, filename, ...args);
+ } finally {
+ mod._compile = originalCompile;
+ }
+ };
+ }
+
+ function installGooglechatOutboundAuthPatch() {
+ var Module = require("module");
+ var originalJsLoader = Module._extensions && Module._extensions[".js"];
+ if (typeof originalJsLoader === "function") {
+ Module._extensions[".js"] = createComposableJsLoader(originalJsLoader);
+ }
+
+ if (typeof Module.registerHooks === "function") {
+ Module.registerHooks({
+ load: function nemoclawGooglechatLoadHook(
+ urlValue: string,
+ context: unknown,
+ nextLoad: NextModuleLoad,
+ ) {
+ var result = nextLoad(urlValue, context);
+ var filename = fileNameFromModuleUrl(urlValue);
+ if (!isOpenClawGooglechatFile(filename)) return result;
+ var sourceText = sourceToText(result && result.source);
+ if (sourceText === null) return result;
+ var patched = patchGooglechatOutboundAuthSource(sourceText, filename);
+ if (patched === sourceText) return result;
+ return Object.assign({}, result, { source: patched });
+ },
+ });
+ }
+ }
+
+ outboundAuthPatchInternals.patchSource = patchGooglechatOutboundAuthSource;
+ outboundAuthPatchInternals.buildShortCircuit = buildBearerShortCircuitSource;
+ outboundAuthPatchInternals.isPatchError = isGooglechatOutboundAuthPatchError;
+ outboundAuthPatchInternals.isOpenClawGooglechatFile = isOpenClawGooglechatFile;
+ outboundAuthPatchInternals.createComposableJsLoader = createComposableJsLoader;
+
+ try {
+ installGooglechatOutboundAuthPatch();
+ process.stderr.write(
+ "[channels] [googlechat] outbound-auth patch active " +
+ "(gateway-minted bearer via " +
+ ENV_VAR +
+ ")\n",
+ );
+ } catch (e) {
+ if (isGooglechatOutboundAuthPatchError(e)) {
+ // Shape drift: surface loudly but do not crash gateway startup. With the
+ // patch not applied the plugin's outbound auth runs unmodified and cannot use
+ // the gateway credential, so the channel breaks loudly on send rather than
+ // degrading silently. Boot/health-time fail-closed on drift is a tracked
+ // follow-up.
+ process.stderr.write(
+ "[channels] [googlechat] outbound-auth patch NOT applied: " + errorMessage(e) + "\n",
+ );
+ }
+ // Any other failure: never break gateway boot.
+ }
+})();
diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.test.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.test.ts
new file mode 100644
index 0000000000..827ba25072
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.test.ts
@@ -0,0 +1,158 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from "vitest";
+import { outboundAuthPatchInternals } from "./googlechat-outbound-auth";
+import { trustedProxyFetchPatchInternals } from "./googlechat-trusted-proxy-fetch";
+
+type CommonJsLoader = (
+ this: unknown,
+ mod: { _compile: (source: string, filename: string) => unknown },
+ filename: string,
+) => unknown;
+
+const { patchSource, isPatchError, isOpenClawGooglechatFile, createComposableJsLoader } =
+ trustedProxyFetchPatchInternals as {
+ patchSource: (source: string, filename: string) => string;
+ isPatchError: (reason: unknown) => boolean;
+ isOpenClawGooglechatFile: (filename: string) => boolean;
+ createComposableJsLoader: (loader: CommonJsLoader) => CommonJsLoader;
+ };
+const { createComposableJsLoader: createOutboundAuthJsLoader } = outboundAuthPatchInternals as {
+ createComposableJsLoader: (loader: CommonJsLoader) => CommonJsLoader;
+};
+
+const FILE = "/x/node_modules/@openclaw/googlechat/dist/api-XXXX.js";
+const PATCH_MARKER = "nemoclaw:gc-trusted-proxy";
+
+// Representative slice of the plugin's google-auth/api bundle carrying all three
+// fetch call sites the patch targets, plus the BUNDLE_MARKER that identifies it.
+const BUNDLE = [
+ 'const GOOGLE_AUTH_AUDIT_CONTEXT = "googlechat.auth";',
+ "function createGoogleAuthFetch(opts) {",
+ " return fetchWithSsrFGuard({",
+ " auditContext: GOOGLE_AUTH_AUDIT_CONTEXT,",
+ " dispatcherPolicy: opts.dispatcherPolicy,",
+ " });",
+ "}",
+ "function fetchChatCerts() {",
+ " return fetchWithSsrFGuard({",
+ " url: CHAT_CERTS_URL,",
+ ' auditContext: "googlechat.auth.certs",',
+ " });",
+ "}",
+ "function withGoogleChatResponse(url, init) {",
+ " return fetchWithSsrFGuard({",
+ " url,",
+ " init,",
+ " });",
+ "}",
+ "async function getGoogleChatAccessToken(account) {",
+ " return (await account.auth.getClient()).getAccessToken();",
+ "}",
+].join("\n");
+
+describe("googlechat trusted-proxy-fetch patch", () => {
+ it("matches the external-extension install path, not only the package path", () => {
+ // Bundled/package load path (pre-2026.6.10).
+ expect(
+ isOpenClawGooglechatFile("/x/node_modules/@openclaw/googlechat/dist/channel.adapters-A.js"),
+ ).toBe(true);
+ // External-extension install path (openclaw plugins install → 2026.6.10 sandbox).
+ expect(
+ isOpenClawGooglechatFile(
+ "/sandbox/.openclaw/extensions/googlechat/dist/channel.adapters-DqnXEL1u.js",
+ ),
+ ).toBe(true);
+ // Other channels and non-.js files stay untouched.
+ expect(isOpenClawGooglechatFile("/sandbox/.openclaw/extensions/slack/dist/index.js")).toBe(
+ false,
+ );
+ expect(isOpenClawGooglechatFile("/x/extensions/googlechat/dist/index.ts")).toBe(false);
+ });
+
+ it("rewrites all three googleapis fetch sites when the bundle matches", () => {
+ const patched = patchSource(BUNDLE, FILE);
+ expect(patched).not.toBe(BUNDLE);
+ expect(patched).toContain(PATCH_MARKER);
+ // Site A: proxy-mode-conditional spread injected.
+ expect(patched).toContain('.dispatcherPolicy?.mode === "explicit-proxy"');
+ // Both trusted modes appear (Site A ternary + Sites B/C).
+ expect(patched).toContain("trusted_explicit_proxy");
+ expect(patched).toContain("trusted_env_proxy");
+ });
+
+ it("maps Site A explicit-proxy to trusted_explicit_proxy and keeps the dispatcherPolicy", () => {
+ const patched = patchSource(BUNDLE, FILE);
+ expect(patched).toContain(
+ '...(opts.dispatcherPolicy?.mode === "explicit-proxy" ' +
+ '? { mode: "trusted_explicit_proxy", dispatcherPolicy: opts.dispatcherPolicy } ' +
+ ': { mode: "trusted_env_proxy" })',
+ );
+ });
+
+ it("adds trusted_env_proxy to Site B (fetchChatCerts) and Site C (outbound wrapper)", () => {
+ const patched = patchSource(BUNDLE, FILE);
+ // Site B: mode inserted between the url and auditContext of the certs fetch.
+ expect(patched).toContain('url: CHAT_CERTS_URL, mode: "trusted_env_proxy",');
+ // Site C: mode inserted at the head of the outbound-wrapper options.
+ expect(patched).toMatch(/fetchWithSsrFGuard\(\{\s*mode: "trusted_env_proxy",\s*url,/);
+ });
+
+ it("is idempotent — already-patched source is returned unchanged", () => {
+ const once = patchSource(BUNDLE, FILE);
+ expect(patchSource(once, FILE)).toBe(once);
+ });
+
+ it("passes through files that are not the google-auth/api bundle", () => {
+ const other = "export function unrelated() {\n return fetchWithSsrFGuard({ url, });\n}";
+ expect(patchSource(other, FILE)).toBe(other);
+ });
+
+ it("throws naming the drifted anchor when a call site is missing", () => {
+ // Bundle marker present + Sites A/B, but the outbound wrapper (Site C) drifted away.
+ const drift = BUNDLE.replace(
+ "function withGoogleChatResponse(url, init) {\n return fetchWithSsrFGuard({\n url,\n init,\n });\n}",
+ "",
+ );
+ expect(() => patchSource(drift, FILE)).toThrow(/withGoogleChatResponse outbound fetch/);
+ expect(() => patchSource(drift, FILE)).toThrow(/not recognized/);
+ let caught: unknown;
+ try {
+ patchSource(drift, FILE);
+ } catch (error) {
+ caught = error;
+ }
+ expect(isPatchError(caught)).toBe(true);
+ });
+
+ it("isPatchError distinguishes anchor-drift errors from unrelated ones", () => {
+ expect(isPatchError(new Error("some unrelated failure"))).toBe(false);
+ expect(
+ isPatchError(
+ new Error("OpenClaw Google Chat trusted-proxy fetch anchors not recognized in x.js [..]"),
+ ),
+ ).toBe(true);
+ });
+
+ it.each([
+ ["trusted proxy then outbound auth", createOutboundAuthJsLoader, createComposableJsLoader],
+ ["outbound auth then trusted proxy", createComposableJsLoader, createOutboundAuthJsLoader],
+ ])("composes the CommonJS rewrites in either preload order: %s", (_label, outer, inner) => {
+ let compiledSource = "";
+ const mod = {
+ _compile(source: string) {
+ compiledSource = source;
+ },
+ };
+ const baseLoader: CommonJsLoader = (loadedModule, filename) =>
+ loadedModule._compile(BUNDLE, filename);
+
+ outer(inner(baseLoader))(mod, FILE);
+
+ expect(compiledSource).toContain(PATCH_MARKER);
+ expect(compiledSource).toContain(
+ "nemoclaw: googlechat outbound bearer via gateway-minted credential",
+ );
+ });
+});
diff --git a/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts
new file mode 100644
index 0000000000..419d1c90bf
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts
@@ -0,0 +1,323 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+// googlechat-trusted-proxy-fetch.ts — load-time patch that routes the Google
+// Chat plugin's googleapis fetches through the TRUSTED ENV PROXY instead of
+// resolving hostnames locally. It replaces the earlier DNS-resolve shim, which
+// answered the googleapis hosts with a public sentinel IP so the SSRF gate's
+// local getaddrinfo would pass; that shim has been removed.
+//
+// ── Workaround Analysis ──────────────────────────────────────────────────────
+// 1. What: rewrites the plugin's three googleapis fetch call sites (dist bundle)
+// to a trusted guard mode so they route by hostname through the env proxy.
+// 2. Why: the sandbox netns is DNS-less; the plugin's fetches default to STRICT
+// SSRF mode (local getaddrinfo), which fails EAI_AGAIN with no local DNS.
+// 3. Alts: no config/env toggle exists to request trusted mode; the prior
+// DNS-sentinel-IP shim was removed as hackier; upstream fix (5) unshipped.
+// 4. Risk: version-sensitive (keys on bundle anchors) but bounded — plugin is
+// integrity-version-pinned, anchors are unit-tested, and drift fails loud
+// (named patch error), never a silent downgrade; Google hosts only, so no
+// SSRF widening.
+// 5. Exit: upstream @openclaw/googlechat requests trusted-env-proxy mode under a
+// managed/env proxy (mirror of web_fetch openclaw#50650) — see below.
+//
+// ── The story: what this changes, and why ────────────────────────────────────
+// The sandbox netns is DNS-less BY DESIGN — all name resolution and egress go
+// through the L7 proxy (10.200.0.1:3128). OpenClaw's SSRF fetch guard
+// (`fetchWithSsrFGuard`) defaults to STRICT mode, which does a LOCAL
+// `getaddrinfo` (SSRF IP-pinning) before connecting. In the DNS-less netns that
+// local lookup fails with EAI_AGAIN, so every googleapis fetch dies.
+//
+// The guard already supports the correct behavior via `mode: "trusted_env_proxy"`:
+// it SKIPS the local resolve, keeps the pre-DNS hostname/policy checks, and hands
+// the hostname to the env proxy (which resolves + enforces policy). But the
+// @openclaw/googlechat plugin does not request that mode:
+// • createGoogleAuthFetch() (inbound cert verify + google-auth) passes a proxy
+// `dispatcherPolicy` (gaxios injects an env-derived proxy agent, so the mode
+// is "explicit-proxy"); the guard's plain `canUseTrustedEnvProxy` gate
+// requires `!dispatcherPolicy`, so it falls to the local-resolve pin branch.
+// • fetchChatCerts() (inbound chat-cert verify) and withGoogleChatResponse()
+// (ALL outbound sends/edits/uploads) pass neither `mode` nor a policy, so
+// they default to STRICT + local resolve too.
+//
+// This preload rewrites those three call sites in the plugin's dist bundle to a
+// trusted guard mode (see the per-site mapping below): explicit-proxy →
+// `trusted_explicit_proxy` (keep the dispatcherPolicy), otherwise
+// `trusted_env_proxy`. The guard's own `shouldUseEnvHttpProxyForUrl` /
+// explicit-proxy checks mean the trusted path only activates when a proxy is
+// actually configured (the sandbox); outside a proxy (dev/CI) it degrades to the
+// normal pinned path, so behavior is unchanged there. All three call sites target
+// Google hosts only (google-auth is confined by GOOGLE_AUTH_POLICY; the others
+// hit fixed googleapis/chat URLs), so this does not broaden SSRF exposure for any
+// user-influenced URL.
+//
+// Net effect: inbound cert verification and outbound replies both route by
+// hostname through the trusted proxy — no local getaddrinfo, no sentinel IP.
+//
+// ── Long-term fix (remove this once it lands) ─────────────────────────────────
+// This is exactly the upstream OpenClaw change (mirror of web_fetch openclaw#50650):
+// make @openclaw/googlechat's createGoogleAuthFetch / fetchChatCerts / outbound
+// request wrapper use trusted-env-proxy mode under a managed/env proxy. When that
+// ships, delete this preload and its `runtime.openclaw.nodePreloads` entry.
+//
+// Mechanism mirrors slack-channel-guard.ts / googlechat-outbound-auth.ts
+// (load-time source rewrite of the @openclaw/googlechat dist bundle via the
+// module loader hooks). It targets the SAME dist chunk that
+// googlechat-outbound-auth already patches, so the CommonJS wrappers compose via
+// Module._compile and both transforms apply regardless of preload order.
+
+// Test seam: the self-installing IIFE below publishes its pure source-rewrite
+// helpers here so unit tests can exercise the anchor rewrites, drift handling, and
+// idempotency directly. Requiring this module still installs the loader hooks, but
+// that install is inert for files outside @openclaw/googlechat.
+type CommonJsModuleLike = {
+ _compile?: (source: unknown, filename?: string) => unknown;
+};
+
+type CommonJsLoader = (
+ this: unknown,
+ mod: CommonJsModuleLike,
+ filename: string,
+ ...args: unknown[]
+) => unknown;
+
+type ModuleLoadResult = Record & { source?: unknown };
+type NextModuleLoad = (urlValue: string, context: unknown) => ModuleLoadResult;
+
+type TrustedProxyFetchPatchInternals = {
+ patchSource: (source: string, filename: string) => string;
+ isPatchError: (reason: unknown) => boolean;
+ isOpenClawGooglechatFile: (filename: unknown) => boolean;
+ createComposableJsLoader: (loader: CommonJsLoader) => CommonJsLoader;
+};
+
+export const trustedProxyFetchPatchInternals = {} as TrustedProxyFetchPatchInternals;
+
+(function () {
+ "use strict";
+
+ // Idempotency / shape markers.
+ var PATCH_MARKER = "nemoclaw:gc-trusted-proxy";
+ // Only the dist chunk that defines the google-auth fetch factory is a target;
+ // this constant is defined right beside it, so its presence identifies the bundle.
+ var BUNDLE_MARKER = "GOOGLE_AUTH_AUDIT_CONTEXT";
+
+ var processWithPatchMarker = process as NodeJS.Process & {
+ __nemoclawGooglechatTrustedProxyFetchInstalled?: boolean;
+ };
+ if (processWithPatchMarker.__nemoclawGooglechatTrustedProxyFetchInstalled) return;
+ try {
+ Object.defineProperty(
+ processWithPatchMarker,
+ "__nemoclawGooglechatTrustedProxyFetchInstalled",
+ {
+ value: true,
+ },
+ );
+ } catch (_e) {
+ processWithPatchMarker.__nemoclawGooglechatTrustedProxyFetchInstalled = true;
+ }
+
+ function isOpenClawGooglechatFile(filename: unknown): boolean {
+ var normalized = String(filename || "").replace(/\\/g, "/");
+ if (!normalized.endsWith(".js")) return false;
+ // The plugin loads either from a package path (/@openclaw/googlechat/) or, when
+ // installed as an external extension (openclaw plugins install), flat from
+ // ~/.openclaw/extensions/googlechat/dist/. Match both; the BUNDLE_MARKER gate in
+ // patchTrustedProxyFetchSource still confines the rewrite to the google-auth chunk.
+ return (
+ normalized.indexOf("/@openclaw/googlechat/") !== -1 ||
+ normalized.indexOf("/extensions/googlechat/") !== -1
+ );
+ }
+
+ // Site A — createGoogleAuthFetch: the guarded fetch passes an env-proxy
+ // dispatcherPolicy that blocks the trusted-env gate. When the resolved policy
+ // is env-proxy, request trusted-env-proxy mode and drop the dispatcherPolicy so
+ // the guard skips the local resolve; otherwise leave the call untouched.
+ var ANCHOR_A = /dispatcherPolicy:\s*([A-Za-z_$][\w$]*)\.dispatcherPolicy\s*,/;
+
+ // Site B — fetchChatCerts: STRICT + no dispatcherPolicy. Add trusted-env-proxy mode.
+ var ANCHOR_B = /(url:\s*CHAT_CERTS_URL\s*,)(\s*auditContext:\s*"googlechat\.auth\.certs")/;
+
+ // Site C — withGoogleChatResponse (the single outbound wrapper): STRICT + no
+ // dispatcherPolicy. Add trusted-env-proxy mode at the head of the options.
+ // Matched by the `url` shorthand immediately after the call open-brace, which
+ // uniquely identifies this call (the google-auth call opens with auditContext).
+ var ANCHOR_C = /fetchWithSsrFGuard\(\{(\s*)url\s*,/;
+
+ function patchTrustedProxyFetchSource(source: string, filename: string): string {
+ // Not the plugin's google-auth/api bundle — pass through untouched.
+ if (source.indexOf(BUNDLE_MARKER) === -1) return source;
+ // Already patched (idempotent across repeated --require of this preload).
+ if (source.indexOf(PATCH_MARKER) !== -1) return source;
+
+ var missing: string[] = [];
+ if (!ANCHOR_A.test(source)) missing.push("createGoogleAuthFetch dispatcherPolicy");
+ if (!ANCHOR_B.test(source)) missing.push("fetchChatCerts (googlechat.auth.certs)");
+ if (!ANCHOR_C.test(source)) missing.push("withGoogleChatResponse outbound fetch");
+ if (missing.length > 0) {
+ // The bundle defines the google-auth factory but one or more call sites
+ // drifted (e.g. a new plugin version). Fail loud rather than silently
+ // leave any googleapis fetch on the local-resolve path (which would break
+ // once the DNS shim is retired).
+ throw new Error(
+ "OpenClaw Google Chat trusted-proxy fetch anchors not recognized in " +
+ filename +
+ " [" +
+ missing.join("; ") +
+ "]; trusted-proxy-fetch patch not applied",
+ );
+ }
+
+ var patched = source;
+ // Site A (createGoogleAuthFetch): the google-auth transport carries a proxy
+ // dispatcherPolicy derived from HTTPS_PROXY. gaxios injects a proxy AGENT, so
+ // resolveGoogleAuthDispatcherPolicy returns mode "explicit-proxy" (not
+ // "env-proxy"). Map each proxy mode to its trusted (no-local-resolve) guard
+ // mode: explicit-proxy → trusted_explicit_proxy (KEEP the dispatcherPolicy,
+ // which sets allowPrivateProxy for the 10.200.0.1 proxy); anything else
+ // (env-proxy / direct / none) → trusted_env_proxy (drop the dispatcherPolicy;
+ // the guard's shouldUseEnvHttpProxyForUrl gate uses HTTPS_PROXY, which the
+ // sandbox always sets). Both branches skip the STRICT local getaddrinfo.
+ patched = patched.replace(
+ ANCHOR_A,
+ '...($1.dispatcherPolicy?.mode === "explicit-proxy" ' +
+ '? { mode: "trusted_explicit_proxy", dispatcherPolicy: $1.dispatcherPolicy } ' +
+ ': { mode: "trusted_env_proxy" }), /* ' +
+ PATCH_MARKER +
+ " */",
+ );
+ // Site B (fetchChatCerts) + Site C (withGoogleChatResponse outbound): STRICT,
+ // no dispatcherPolicy → just request trusted_env_proxy (guard skips the local
+ // resolve when HTTPS_PROXY is configured; degrades to the normal pinned path
+ // otherwise).
+ patched = patched.replace(ANCHOR_B, '$1 mode: "trusted_env_proxy",$2');
+ patched = patched.replace(ANCHOR_C, 'fetchWithSsrFGuard({$1mode: "trusted_env_proxy",$1url,');
+ // Confirm the rewrite actually happened (the "active" banner only proves the
+ // loader hook was installed, not that a file was patched).
+ process.stderr.write(
+ "[channels] [googlechat] trusted-proxy-fetch: rewrote googleapis fetch sites in " +
+ filename +
+ "\n",
+ );
+ return patched;
+ }
+
+ function errorMessage(reason: unknown): string {
+ if (typeof reason === "object" && reason !== null && "message" in reason) {
+ return String(Reflect.get(reason, "message") ?? "");
+ }
+ return String(reason ?? "");
+ }
+
+ function isTrustedProxyFetchPatchError(reason: unknown): boolean {
+ var msg = errorMessage(reason);
+ return (
+ msg.indexOf("OpenClaw Google Chat trusted-proxy fetch anchors") !== -1 &&
+ msg.indexOf("not recognized") !== -1
+ );
+ }
+
+ function fileNameFromModuleUrl(urlValue: unknown): string {
+ if (typeof urlValue !== "string" || !urlValue.startsWith("file:")) return "";
+ try {
+ return require("url").fileURLToPath(urlValue);
+ } catch (_e) {
+ return "";
+ }
+ }
+
+ function sourceToText(source: unknown): string | null {
+ if (typeof source === "string") return source;
+ if (typeof Buffer !== "undefined") {
+ if (Buffer.isBuffer(source)) return source.toString("utf8");
+ if (source instanceof Uint8Array) return Buffer.from(source).toString("utf8");
+ if (source instanceof ArrayBuffer) return Buffer.from(source).toString("utf8");
+ }
+ return null;
+ }
+
+ // Compose with other CommonJS source-rewrite preloads by intercepting
+ // Module._compile instead of reading + compiling the file directly. Calling the
+ // previous loader lets each wrapper transform the same source in sequence,
+ // regardless of NODE_OPTIONS preload order.
+ function createComposableJsLoader(originalJsLoader: CommonJsLoader): CommonJsLoader {
+ return function nemoclawGooglechatTrustedProxyJsLoader(
+ this: unknown,
+ mod: CommonJsModuleLike,
+ filename: string,
+ ...args: unknown[]
+ ) {
+ if (!isOpenClawGooglechatFile(filename) || typeof mod._compile !== "function") {
+ return originalJsLoader.call(this, mod, filename, ...args);
+ }
+
+ var originalCompile = mod._compile;
+ mod._compile = function nemoclawGooglechatTrustedProxyCompile(
+ this: unknown,
+ source: unknown,
+ loadedFilename?: string,
+ ) {
+ var sourceText = sourceToText(source);
+ var patched =
+ sourceText === null
+ ? source
+ : patchTrustedProxyFetchSource(sourceText, loadedFilename || filename);
+ return originalCompile.call(this, patched, loadedFilename);
+ };
+ try {
+ return originalJsLoader.call(this, mod, filename, ...args);
+ } finally {
+ mod._compile = originalCompile;
+ }
+ };
+ }
+
+ function installTrustedProxyFetchPatch() {
+ var Module = require("module");
+ var originalJsLoader = Module._extensions && Module._extensions[".js"];
+ if (typeof originalJsLoader === "function") {
+ Module._extensions[".js"] = createComposableJsLoader(originalJsLoader);
+ }
+
+ if (typeof Module.registerHooks === "function") {
+ Module.registerHooks({
+ load: function nemoclawGooglechatTrustedProxyLoadHook(
+ urlValue: string,
+ context: unknown,
+ nextLoad: NextModuleLoad,
+ ) {
+ var result = nextLoad(urlValue, context);
+ var filename = fileNameFromModuleUrl(urlValue);
+ if (!isOpenClawGooglechatFile(filename)) return result;
+ var sourceText = sourceToText(result && result.source);
+ if (sourceText === null) return result;
+ var patched = patchTrustedProxyFetchSource(sourceText, filename);
+ if (patched === sourceText) return result;
+ return Object.assign({}, result, { source: patched });
+ },
+ });
+ }
+ }
+
+ trustedProxyFetchPatchInternals.patchSource = patchTrustedProxyFetchSource;
+ trustedProxyFetchPatchInternals.isPatchError = isTrustedProxyFetchPatchError;
+ trustedProxyFetchPatchInternals.isOpenClawGooglechatFile = isOpenClawGooglechatFile;
+ trustedProxyFetchPatchInternals.createComposableJsLoader = createComposableJsLoader;
+
+ try {
+ installTrustedProxyFetchPatch();
+ process.stderr.write(
+ "[channels] [googlechat] trusted-proxy-fetch patch active " +
+ "(googleapis fetches routed via trusted env proxy; no local DNS resolve)\n",
+ );
+ } catch (e) {
+ if (isTrustedProxyFetchPatchError(e)) {
+ process.stderr.write(
+ "[channels] [googlechat] trusted-proxy-fetch patch NOT applied: " + errorMessage(e) + "\n",
+ );
+ }
+ // Any other failure: never break gateway boot.
+ }
+})();
diff --git a/src/lib/messaging/channels/googlechat/template-resolver.test.ts b/src/lib/messaging/channels/googlechat/template-resolver.test.ts
new file mode 100644
index 0000000000..85cb3d5330
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/template-resolver.test.ts
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from "vitest";
+
+import type { SandboxMessagingInputReference } from "../../manifest";
+import { resolveGooglechatTemplateReference } from "./template-resolver";
+
+function configInput(
+ inputId: string,
+ statePath: string,
+ value: string,
+): SandboxMessagingInputReference {
+ return { channelId: "googlechat", inputId, kind: "config", required: false, statePath, value };
+}
+
+describe("Google Chat template resolver", () => {
+ it("defaults audienceType when unset", () => {
+ const inputs: SandboxMessagingInputReference[] = [];
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.audienceType", { inputs })?.value,
+ ).toBe("app-url");
+ });
+
+ it("passes through configured values; drops audience but seeds the appPrincipal sentinel when unset", () => {
+ const set: SandboxMessagingInputReference[] = [
+ configInput("audience", "googlechatConfig.audience", "https://x.example/googlechat"),
+ configInput("appPrincipal", "googlechatConfig.appPrincipal", "103987852733692332624"),
+ configInput("audienceType", "googlechatConfig.audienceType", "project-number"),
+ ];
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.audience", { inputs: set })?.value,
+ ).toBe("https://x.example/googlechat");
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.appPrincipal", { inputs: set })?.value,
+ ).toBe("103987852733692332624");
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.audienceType", { inputs: set })?.value,
+ ).toBe("project-number");
+
+ // Unset audience → undefined so the render engine drops the key entirely.
+ // Unset appPrincipal → the all-zeros discovery sentinel (so the first DM
+ // surfaces the real value) rather than dropping the key.
+ const empty: SandboxMessagingInputReference[] = [];
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.audience", { inputs: empty })?.value,
+ ).toBeUndefined();
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.appPrincipal", { inputs: empty })?.value,
+ ).toBe("000000000000000000000");
+ });
+
+ it("normalizes the DM allowlist into nested dm.policy / dm.allowFrom", () => {
+ const withIds: SandboxMessagingInputReference[] = [
+ configInput("allowFrom", "allowedIds.googlechat", "users/111, user@example.com"),
+ ];
+ expect(
+ resolveGooglechatTemplateReference("allowedIds.googlechat.dmPolicy", { inputs: withIds })
+ ?.value,
+ ).toBe("allowlist");
+ expect(
+ resolveGooglechatTemplateReference("allowedIds.googlechat.values", { inputs: withIds })
+ ?.value,
+ ).toEqual(["users/111", "user@example.com"]);
+
+ // No allowlist → both undefined so the whole `dm` object drops out.
+ const noIds: SandboxMessagingInputReference[] = [];
+ expect(
+ resolveGooglechatTemplateReference("allowedIds.googlechat.dmPolicy", { inputs: noIds })
+ ?.value,
+ ).toBeUndefined();
+ expect(
+ resolveGooglechatTemplateReference("allowedIds.googlechat.values", { inputs: noIds })?.value,
+ ).toBeUndefined();
+ });
+
+ it("returns undefined for references it does not own", () => {
+ expect(resolveGooglechatTemplateReference("teamsConfig.appId", { inputs: [] })).toBeUndefined();
+ });
+});
diff --git a/src/lib/messaging/channels/googlechat/template-resolver.ts b/src/lib/messaging/channels/googlechat/template-resolver.ts
new file mode 100644
index 0000000000..5d39ae69e1
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/template-resolver.ts
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ allowedIds,
+ type BuiltInRenderTemplateResolver,
+ nonEmptyArray,
+ nonEmptyString,
+ resolvedRenderTemplateReference,
+ stateValue,
+} from "../template-resolver-utils";
+
+const DEFAULT_AUDIENCE_TYPE = "app-url";
+
+// When appPrincipal is left blank we render this all-zeros discovery sentinel
+// instead of dropping the key. It only matters for personal/standalone (add-on)
+// accounts: on the first inbound DM, OpenClaw compares the token's real add-on
+// principal against this value, they mismatch, and it logs
+// `unexpected add-on principal: ` — surfacing , the real appPrincipal to
+// copy. Without a seeded value the log instead says `missing add-on principal
+// binding` with no number, so "leave blank" would be a dead end. Inert for
+// Google Workspace accounts: their inbound token issuer (chat@system…) is
+// approved before appPrincipal is ever read, and all-zeros can never collide
+// with a real Google-assigned principal.
+const APP_PRINCIPAL_DISCOVERY_SENTINEL = "000000000000000000000";
+
+export const resolveGooglechatTemplateReference: BuiltInRenderTemplateResolver = (
+ reference,
+ context,
+) => {
+ switch (reference) {
+ case "googlechatConfig.audienceType":
+ return resolvedRenderTemplateReference(
+ nonEmptyString(stateValue(context, "googlechatConfig.audienceType")) ??
+ DEFAULT_AUDIENCE_TYPE,
+ );
+ case "googlechatConfig.audience":
+ return resolvedRenderTemplateReference(
+ nonEmptyString(stateValue(context, "googlechatConfig.audience")),
+ );
+ case "googlechatConfig.appPrincipal":
+ return resolvedRenderTemplateReference(
+ nonEmptyString(stateValue(context, "googlechatConfig.appPrincipal")) ??
+ APP_PRINCIPAL_DISCOVERY_SENTINEL,
+ );
+ default:
+ break;
+ }
+
+ // DM allowlist normalization. `values` resolving to undefined drops the
+ // `allowFrom` key; `dmPolicy` resolving to undefined drops `dm.policy`. When
+ // both drop, the empty `dm` object is removed by the render engine and
+ // OpenClaw falls back to its default (pairing) DM policy.
+ const allowReference = reference.match(/^allowedIds[.]googlechat[.](values|dmPolicy)$/);
+ if (!allowReference?.[1]) return undefined;
+ const ids = allowedIds(context, "googlechat");
+ switch (allowReference[1]) {
+ case "values":
+ return resolvedRenderTemplateReference(nonEmptyArray(ids));
+ case "dmPolicy":
+ return resolvedRenderTemplateReference(ids.length > 0 ? "allowlist" : undefined);
+ default:
+ return undefined;
+ }
+};
diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts
index f1c1b61b4f..5cd1c9f3d0 100644
--- a/src/lib/messaging/channels/manifests.test.ts
+++ b/src/lib/messaging/channels/manifests.test.ts
@@ -17,6 +17,7 @@ describe("built-in channel manifests", () => {
it("registers every known channel for supported gateway agents", () => {
const registry = createBuiltInChannelManifestRegistry();
const channelNames = knownChannelNames();
+ const hermesChannelNames = channelNames.filter((channelName) => channelName !== "googlechat");
expect(BUILT_IN_CHANNEL_MANIFESTS.map((manifest) => manifest.id)).toEqual(channelNames);
expect(registry.list().map((manifest) => manifest.id)).toEqual(channelNames);
@@ -24,7 +25,7 @@ describe("built-in channel manifests", () => {
channelNames,
);
expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual(
- channelNames,
+ hermesChannelNames,
);
});
@@ -88,6 +89,10 @@ describe("built-in channel manifests", () => {
"src/lib/messaging/channels/whatsapp/manifest.ts",
"src/lib/messaging/channels/teams/manifest.ts",
"src/lib/messaging/channels/teams/hooks/host-forward-port-conflict.ts",
+ "src/lib/messaging/channels/googlechat/manifest.ts",
+ "src/lib/messaging/channels/googlechat/hooks/index.ts",
+ "src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate.ts",
+ "src/lib/messaging/channels/googlechat/template-resolver.ts",
"src/lib/messaging/hooks/common/config-prompt.ts",
"src/lib/messaging/hooks/common/token-paste.ts",
];
diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts
index f4107b3a8c..08f3ad108c 100644
--- a/src/lib/messaging/channels/metadata.test.ts
+++ b/src/lib/messaging/channels/metadata.test.ts
@@ -33,6 +33,7 @@ describe("built-in messaging channel metadata", () => {
"slack",
"whatsapp",
"teams",
+ "googlechat",
]);
expect(listAvailableMessagingChannelIds({ agent: "hermes" })).toEqual([
"telegram",
@@ -66,7 +67,7 @@ describe("built-in messaging channel metadata", () => {
"demo-slack-bridge",
"demo-slack-app",
]);
- expect(listMessagingChannelsWithoutCredentials()).toEqual(["whatsapp"]);
+ expect(listMessagingChannelsWithoutCredentials()).toEqual(["whatsapp", "googlechat"]);
});
it("resolves config env keys from manifests and compatibility aliases from metadata", () => {
@@ -89,6 +90,10 @@ describe("built-in messaging channel metadata", () => {
"TEAMS_ALLOWED_USERS",
"MSTEAMS_PORT",
"TEAMS_REQUIRE_MENTION",
+ "GOOGLECHAT_AUDIENCE_TYPE",
+ "GOOGLECHAT_AUDIENCE",
+ "GOOGLECHAT_APP_PRINCIPAL",
+ "GOOGLECHAT_ALLOWED_USERS",
]);
expect(getMessagingConfigEnvAliases()).toEqual({
DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"],
@@ -128,6 +133,7 @@ describe("built-in messaging channel metadata", () => {
"slack",
"whatsapp",
"msteams",
+ "googlechat",
]);
expect(listOpenClawPluginExtensionIds()).toEqual([
"discord",
@@ -135,6 +141,7 @@ describe("built-in messaging channel metadata", () => {
"slack",
"whatsapp",
"msteams",
+ "googlechat",
]);
expect(
Object.fromEntries(
@@ -223,6 +230,11 @@ describe("built-in messaging channel metadata", () => {
committedIntegrity:
"sha512-GjHnCPvjbnI0C7mEFcdT2uKDH4/WwOe2dZBfQiWxBtkE76m6TNG0J9dJjD4mc8/pk8rXSO0cWw+KV9jzWtF9VA==",
},
+ {
+ packageKey: "googlechat/openclawPluginPackage",
+ committedIntegrity:
+ "sha512-3ay7CEcNs2BKj4Y3IkqLIp2B+8RXRmWDFFGPkWk3qSjHRl3rsROpBFUDBbJI+33SzNsAubWum7mOo08by5i7LQ==",
+ },
]);
});
diff --git a/src/lib/messaging/channels/rendered-config-parser.ts b/src/lib/messaging/channels/rendered-config-parser.ts
index 14bf1b81e4..c994577bb1 100644
--- a/src/lib/messaging/channels/rendered-config-parser.ts
+++ b/src/lib/messaging/channels/rendered-config-parser.ts
@@ -4,6 +4,7 @@
import type { ChannelManifest } from "../manifest";
import { BUILT_IN_CHANNEL_MANIFESTS } from "./built-ins";
import { discordRenderedConfigParser } from "./discord/rendered-config-parser";
+import { googlechatRenderedConfigParser } from "./googlechat/rendered-config-parser";
import type { RenderedChannelConfigParser } from "./rendered-config-parser-utils";
import { slackRenderedConfigParser } from "./slack/rendered-config-parser";
import { teamsRenderedConfigParser } from "./teams/rendered-config-parser";
@@ -32,6 +33,8 @@ function renderedConfigParserForBuiltInManifest(
switch (manifest.id) {
case "discord":
return discordRenderedConfigParser;
+ case "googlechat":
+ return googlechatRenderedConfigParser;
case "slack":
return slackRenderedConfigParser;
case "teams":
diff --git a/src/lib/messaging/channels/template-resolver.ts b/src/lib/messaging/channels/template-resolver.ts
index 11c1190b3b..87090c1861 100644
--- a/src/lib/messaging/channels/template-resolver.ts
+++ b/src/lib/messaging/channels/template-resolver.ts
@@ -2,9 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
import { resolveDiscordTemplateReference } from "./discord/template-resolver";
+import { resolveGooglechatTemplateReference } from "./googlechat/template-resolver";
import { resolveSlackTemplateReference } from "./slack/template-resolver";
-import { resolveTelegramTemplateReference } from "./telegram/template-resolver";
import { resolveTeamsTemplateReference } from "./teams/template-resolver";
+import { resolveTelegramTemplateReference } from "./telegram/template-resolver";
import type { BuiltInRenderTemplateResolver } from "./template-resolver-utils";
import { resolveWechatTemplateReference } from "./wechat/template-resolver";
import { resolveWhatsappTemplateReference } from "./whatsapp/template-resolver";
@@ -16,6 +17,7 @@ const BUILT_IN_TEMPLATE_REFERENCE_RESOLVERS: readonly BuiltInRenderTemplateResol
resolveSlackTemplateReference,
resolveWhatsappTemplateReference,
resolveTeamsTemplateReference,
+ resolveGooglechatTemplateReference,
];
export function createBuiltInRenderTemplateResolver(): BuiltInRenderTemplateResolver {
diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts
index c075044aa7..dbe7996a7e 100644
--- a/src/lib/messaging/diagnostics.test.ts
+++ b/src/lib/messaging/diagnostics.test.ts
@@ -16,6 +16,7 @@ describe("messaging channel diagnostics", () => {
"slack",
"whatsapp",
"teams",
+ "googlechat",
]);
expect(specs.find((spec) => spec.channelId === "telegram")).toMatchObject({
policyPresets: ["telegram"],
diff --git a/src/lib/messaging/hooks/builtins.ts b/src/lib/messaging/hooks/builtins.ts
index f181b6eabd..97cbe69f7e 100644
--- a/src/lib/messaging/hooks/builtins.ts
+++ b/src/lib/messaging/hooks/builtins.ts
@@ -2,6 +2,10 @@
// SPDX-License-Identifier: Apache-2.0
import { createDiscordHookRegistrations, type DiscordHookOptions } from "../channels/discord/hooks";
+import {
+ createGooglechatHookRegistrations,
+ type GooglechatHookOptions,
+} from "../channels/googlechat/hooks";
import type { OpenClawBridgeHealthHookOptions } from "../channels/openclaw-bridge-health";
import { createSlackHookRegistrations, type SlackHookOptions } from "../channels/slack/hooks";
import { createTeamsHookRegistrations, type TeamsHookOptions } from "../channels/teams/hooks";
@@ -17,6 +21,7 @@ import type { MessagingHookRegistration } from "./types";
export interface BuiltInMessagingHookOptions {
readonly common?: CommonHookOptions;
readonly discord?: DiscordHookOptions;
+ readonly googlechat?: GooglechatHookOptions;
readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions;
readonly slack?: SlackHookOptions;
readonly teams?: TeamsHookOptions;
@@ -32,6 +37,7 @@ export function createBuiltInMessagingHookRegistrations(
...createDiscordHookRegistrations(
withOpenClawBridgeHealthOptions(options.discord, options.openclawBridgeHealth),
),
+ ...createGooglechatHookRegistrations(options.googlechat),
...createSlackHookRegistrations(
withOpenClawBridgeHealthOptions(options.slack, options.openclawBridgeHealth),
),
diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts
index 2c9d9cf163..61522cff16 100644
--- a/src/lib/messaging/hooks/hook-runner.test.ts
+++ b/src/lib/messaging/hooks/hook-runner.test.ts
@@ -38,6 +38,7 @@ describe("MessagingHookRegistry", () => {
"common.tokenPaste",
"common.configPrompt",
"discord.openclawBridgeHealth",
+ "googlechat.tunnelAudienceGate",
"slack.socketModeGatewayConflict",
"slack.socketModeGatewayStatus",
"slack.openclawBridgeHealth",
diff --git a/src/lib/messaging/utils.test.ts b/src/lib/messaging/utils.test.ts
index 1fc16bdc0e..f423466bfb 100644
--- a/src/lib/messaging/utils.test.ts
+++ b/src/lib/messaging/utils.test.ts
@@ -33,6 +33,7 @@ describe("listSupportedMessagingChannelIdsForAgent", () => {
"slack",
"whatsapp",
"teams",
+ "googlechat",
]);
expect(listSupportedMessagingChannelIdsForAgent(manifests, "hermes")).toEqual([
"telegram",
diff --git a/src/lib/onboard/messaging-bridge-provider.test.ts b/src/lib/onboard/messaging-bridge-provider.test.ts
new file mode 100644
index 0000000000..cbf042c9f1
--- /dev/null
+++ b/src/lib/onboard/messaging-bridge-provider.test.ts
@@ -0,0 +1,310 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+import {
+ bridgeProviderNamesForChannel,
+ bridgeSecretEnvsForChannel,
+ collectMessagingBridgeTokenDefs,
+ configureMessagingBridgeRefreshes,
+ ensureMessagingBridgeProfiles,
+ listMessagingBridgeProfiles,
+ MESSAGING_BRIDGE_PENDING_VALUE,
+ type MessagingBridgeProfile,
+} from "./messaging-bridge-provider";
+
+const SA_JSON = JSON.stringify({
+ client_email: "bot@p.iam.gserviceaccount.com",
+ private_key: "fake-test-private-key-material",
+});
+const normalizeCredentialValue = (v: unknown) => String(v ?? "").trim();
+const redact = (s: string) => s;
+const noLog = vi.fn();
+
+// Injected in-memory profile mirroring the co-located google-chat-bridge profile,
+// so the unit tests do not touch the filesystem or the manifest registry.
+const GC_PROFILE: MessagingBridgeProfile = {
+ channelId: "googlechat",
+ agent: "openclaw",
+ profilePath: "/repo/src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml",
+ profileId: "google-chat-bridge",
+ credentialKey: "GOOGLE_CHAT_ACCESS_TOKEN",
+ strategy: "google-service-account-jwt",
+ scopes: ["https://www.googleapis.com/auth/chat.bot"],
+ secretMaterialKeys: ["private_key"],
+ sourceSecretEnv: "GOOGLECHAT_SERVICE_ACCOUNT",
+};
+
+const BRIDGE_DEF = {
+ name: "sbx-googlechat-bridge",
+ providerType: GC_PROFILE.profileId,
+ token: MESSAGING_BRIDGE_PENDING_VALUE,
+};
+
+function collectInput(
+ overrides: Partial[0]> = {},
+) {
+ return {
+ sandboxName: "sbx",
+ getCredential: () => null,
+ enabledChannels: ["googlechat"],
+ disabledChannelNames: new Set(),
+ profiles: [GC_PROFILE],
+ ...overrides,
+ };
+}
+
+describe("collectMessagingBridgeTokenDefs", () => {
+ it("returns nothing when the bridge channel is disabled", () => {
+ expect(
+ collectMessagingBridgeTokenDefs(
+ collectInput({
+ getCredential: () => SA_JSON,
+ disabledChannelNames: new Set(["googlechat"]),
+ }),
+ ),
+ ).toEqual([]);
+ });
+
+ it("returns nothing when the bridge channel is not enabled", () => {
+ expect(
+ collectMessagingBridgeTokenDefs(
+ collectInput({ getCredential: () => SA_JSON, enabledChannels: ["slack"] }),
+ ),
+ ).toEqual([]);
+ });
+
+ it("returns nothing when the source secret is unavailable", () => {
+ expect(collectMessagingBridgeTokenDefs(collectInput())).toEqual([]);
+ });
+
+ it("emits the bridge token def when the secret is in the store", () => {
+ expect(collectMessagingBridgeTokenDefs(collectInput({ getCredential: () => SA_JSON }))).toEqual(
+ [
+ {
+ name: "sbx-googlechat-bridge",
+ envKey: GC_PROFILE.credentialKey,
+ token: MESSAGING_BRIDGE_PENDING_VALUE,
+ providerType: GC_PROFILE.profileId,
+ },
+ ],
+ );
+ });
+
+ it("emits the bridge token def from an env-only secret (resolution parity)", () => {
+ const defs = collectMessagingBridgeTokenDefs(
+ collectInput({
+ getCredential: () => null,
+ env: { [GC_PROFILE.sourceSecretEnv]: SA_JSON },
+ normalizeCredentialValue,
+ }),
+ );
+ expect(defs[0]?.providerType).toBe(GC_PROFILE.profileId);
+ expect(defs[0]?.envKey).toBe(GC_PROFILE.credentialKey);
+ });
+});
+
+describe("configureMessagingBridgeRefreshes", () => {
+ it("is a no-op success when there is no bridge token def", () => {
+ const runOpenshell = vi.fn();
+ const result = configureMessagingBridgeRefreshes([], {
+ runOpenshell,
+ redact,
+ getCredential: () => SA_JSON,
+ log: noLog,
+ profiles: [GC_PROFILE],
+ });
+ expect(result).toEqual({ ok: true });
+ expect(runOpenshell).not.toHaveBeenCalled();
+ });
+
+ it("fails closed when the secret is unavailable", () => {
+ const result = configureMessagingBridgeRefreshes([BRIDGE_DEF], {
+ runOpenshell: vi.fn(),
+ redact,
+ getCredential: () => null,
+ log: noLog,
+ profiles: [GC_PROFILE],
+ });
+ expect(result.ok).toBe(false);
+ });
+
+ it("fails closed when the service account JSON cannot be parsed", () => {
+ const result = configureMessagingBridgeRefreshes([BRIDGE_DEF], {
+ runOpenshell: vi.fn(),
+ redact,
+ getCredential: () => "not json",
+ log: noLog,
+ profiles: [GC_PROFILE],
+ });
+ expect(result.ok).toBe(false);
+ });
+
+ it("fails closed when client_email or private_key is missing", () => {
+ const result = configureMessagingBridgeRefreshes([BRIDGE_DEF], {
+ runOpenshell: vi.fn(),
+ redact,
+ getCredential: () => JSON.stringify({ client_email: "x@y" }),
+ log: noLog,
+ profiles: [GC_PROFILE],
+ });
+ expect(result.ok).toBe(false);
+ });
+
+ it("keeps private keys off argv while configuring refresh", () => {
+ const secretEnvName = "MESSAGING_BRIDGE_SECRET_0";
+ const parentSecret = process.env[secretEnvName];
+ const runOpenshell = vi.fn((_args: string[], _opts: { env?: NodeJS.ProcessEnv }) => ({
+ status: 0,
+ }));
+ const result = configureMessagingBridgeRefreshes([BRIDGE_DEF], {
+ runOpenshell,
+ redact,
+ getCredential: () => SA_JSON,
+ log: noLog,
+ profiles: [GC_PROFILE],
+ });
+ expect(result).toEqual({ ok: true });
+ expect(runOpenshell).toHaveBeenCalledTimes(1);
+ const args = runOpenshell.mock.calls[0][0];
+ expect(args.slice(0, 3)).toEqual(["provider", "refresh", "configure"]);
+ expect(args).toContain(GC_PROFILE.credentialKey);
+ expect(args).toContain("google-service-account-jwt");
+ expect(args).toContain("client_email=bot@p.iam.gserviceaccount.com");
+ expect(args).toContain("scope=https://www.googleapis.com/auth/chat.bot");
+ expect(args).toContain("--secret-material-env");
+ expect(args).toContain(`private_key=${secretEnvName}`);
+ expect(args.join(" ")).not.toContain("fake-test-private-key-material");
+ expect(args).toContain("sbx-googlechat-bridge");
+ const options = runOpenshell.mock.calls[0][1];
+ expect(options.env).toEqual({ [secretEnvName]: "fake-test-private-key-material" });
+ expect(process.env[secretEnvName]).toBe(parentSecret);
+ });
+
+ it("fails closed when runOpenshell exits nonzero", () => {
+ const runOpenshell = vi.fn(() => ({ status: 1, stderr: "gateway rejected the material" }));
+ const result = configureMessagingBridgeRefreshes([BRIDGE_DEF], {
+ runOpenshell,
+ redact,
+ getCredential: () => SA_JSON,
+ log: noLog,
+ profiles: [GC_PROFILE],
+ });
+ expect(result.ok).toBe(false);
+ expect(result.reason).toBeTruthy();
+ });
+
+ it("resolves the secret from the injected env too (parity)", () => {
+ const runOpenshell = vi.fn(() => ({ status: 0 }));
+ const result = configureMessagingBridgeRefreshes([BRIDGE_DEF], {
+ runOpenshell,
+ redact,
+ getCredential: () => null,
+ env: { [GC_PROFILE.sourceSecretEnv]: SA_JSON },
+ normalizeCredentialValue,
+ log: noLog,
+ profiles: [GC_PROFILE],
+ });
+ expect(result).toEqual({ ok: true });
+ expect(runOpenshell).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("ensureMessagingBridgeProfiles", () => {
+ const baseDeps = () => ({
+ root: "/repo",
+ redact,
+ log: noLog,
+ exit: vi.fn(() => undefined as never),
+ profiles: [GC_PROFILE],
+ });
+
+ it("does nothing when there is no bridge token def", () => {
+ const runOpenshell = vi.fn();
+ ensureMessagingBridgeProfiles([], { ...baseDeps(), runOpenshell });
+ expect(runOpenshell).not.toHaveBeenCalled();
+ });
+
+ it("imports the profile from its co-located path and does not exit on success", () => {
+ const runOpenshell = vi.fn((_args: string[], _opts: unknown) => ({ status: 0 }));
+ const exit = vi.fn(() => undefined as never);
+ ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit });
+ expect(runOpenshell).toHaveBeenCalledTimes(1);
+ const args = runOpenshell.mock.calls[0][0];
+ expect(args.slice(0, 4)).toEqual(["provider", "profile", "import", "--file"]);
+ expect(args).toContain(GC_PROFILE.profilePath);
+ expect(exit).not.toHaveBeenCalled();
+ });
+
+ it("tolerates an already-registered profile without exiting", () => {
+ const runOpenshell = vi.fn(() => ({ status: 1, stderr: "profile already exists" }));
+ const exit = vi.fn(() => undefined as never);
+ ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit });
+ expect(exit).not.toHaveBeenCalled();
+ });
+
+ it("exits when profile import fails for another reason", () => {
+ const runOpenshell = vi.fn(() => ({ status: 1, stderr: "connection refused" }));
+ const exit = vi.fn(() => undefined as never);
+ ensureMessagingBridgeProfiles([BRIDGE_DEF], { ...baseDeps(), runOpenshell, exit });
+ expect(exit).toHaveBeenCalled();
+ });
+});
+
+describe("listMessagingBridgeProfiles (real registry + co-located YAML)", () => {
+ it("discovers the Google Chat bridge and keeps the credential key in lockstep", () => {
+ const profiles = listMessagingBridgeProfiles();
+ const gc = profiles.find((p) => p.channelId === "googlechat");
+ expect(gc).toBeDefined();
+ expect(gc?.agent).toBe("openclaw");
+ expect(gc?.profileId).toBe("google-chat-bridge");
+ // Invariant: must equal the env var the googlechat-outbound-auth runtime
+ // preload reads, or outbound replies never authenticate.
+ expect(gc?.credentialKey).toBe("GOOGLE_CHAT_ACCESS_TOKEN");
+ expect(gc?.strategy).toBe("google-service-account-jwt");
+ expect(gc?.secretMaterialKeys).toContain("private_key");
+ expect(gc?.sourceSecretEnv).toBe("GOOGLECHAT_SERVICE_ACCOUNT");
+ expect(gc?.profilePath.endsWith("googlechat/provider-profile/openclaw.yaml")).toBe(true);
+ });
+});
+
+describe("bridgeProviderNamesForChannel (PRA-8: channels remove teardown)", () => {
+ it("returns the gateway-minted bridge provider for a credentials:[] channel", () => {
+ // The dangling-provider case: a bridge channel has no channelTokenKeys, so
+ // `channels remove` must still find its provider to detach + delete.
+ expect(bridgeProviderNamesForChannel("sbx", "googlechat", [GC_PROFILE])).toEqual([
+ "sbx-googlechat-bridge",
+ ]);
+ });
+
+ it("returns nothing for a channel that has no bridge profile", () => {
+ expect(bridgeProviderNamesForChannel("sbx", "telegram", [GC_PROFILE])).toEqual([]);
+ });
+
+ it("dedupes when a channel declares the same bridge for multiple agents", () => {
+ expect(
+ bridgeProviderNamesForChannel("sbx", "googlechat", [
+ GC_PROFILE,
+ { ...GC_PROFILE, agent: "hermes" },
+ ]),
+ ).toEqual(["sbx-googlechat-bridge"]);
+ });
+});
+
+describe("bridgeSecretEnvsForChannel", () => {
+ it("names the source-secret env var so enable-time callers can fail loudly", () => {
+ expect(bridgeSecretEnvsForChannel("googlechat", [GC_PROFILE])).toEqual([
+ "GOOGLECHAT_SERVICE_ACCOUNT",
+ ]);
+ });
+
+ it("returns nothing for a channel without a bridge profile", () => {
+ expect(bridgeSecretEnvsForChannel("telegram", [GC_PROFILE])).toEqual([]);
+ });
+
+ it("dedupes across per-agent profiles sharing one secret env", () => {
+ expect(
+ bridgeSecretEnvsForChannel("googlechat", [GC_PROFILE, { ...GC_PROFILE, agent: "hermes" }]),
+ ).toEqual(["GOOGLECHAT_SERVICE_ACCOUNT"]);
+ });
+});
diff --git a/src/lib/onboard/messaging-bridge-provider.ts b/src/lib/onboard/messaging-bridge-provider.ts
new file mode 100644
index 0000000000..add56e2d15
--- /dev/null
+++ b/src/lib/onboard/messaging-bridge-provider.ts
@@ -0,0 +1,479 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+// Generic messaging-channel "bridge provider" wiring.
+//
+// A messaging channel that mints its outbound token gateway-side (so the
+// secret never enters the sandbox) declares an OpenShell provider profile
+// co-located with the channel at
+// src/lib/messaging/channels//provider-profile/.yaml
+// (the same per-channel convention as policy presets, /policy/.yaml).
+//
+// The profile YAML is the single source of truth: it declares the provider `id`
+// (used as `provider create --type `), the injectable credential env var, and
+// the credential-refresh strategy + material shape. This module discovers those
+// profiles by convention and drives the two OpenShell steps that bracket provider
+// creation — `provider profile import` (before) and `provider refresh configure`
+// (after) — for ANY channel that has one, so no channel-specific logic lives in
+// the generic provider-upsert path. Today only Google Chat uses this; a second
+// minted-token channel needs only its own profile YAML.
+
+import fs from "node:fs";
+import path from "node:path";
+import YAML from "yaml";
+
+import { compactText } from "../core/url-utils";
+import { createBuiltInChannelManifestRegistry } from "../messaging/channels";
+import type {
+ ChannelManifest,
+ ChannelSecretInputSpec,
+ MessagingAgentId,
+} from "../messaging/manifest";
+import { ROOT } from "../state/paths";
+
+// Create-time credential sentinel: the real value is minted by
+// `provider refresh configure`; this only has to be non-empty so the provider is
+// created (the gateway overwrites it on the first mint).
+export const MESSAGING_BRIDGE_PENDING_VALUE = "openshell-managed-pending-mint";
+
+const CHANNELS_SUBPATH = ["src", "lib", "messaging", "channels"] as const;
+const PROVIDER_PROFILE_FILE_BY_AGENT: Readonly> = {
+ openclaw: "openclaw.yaml",
+ hermes: "hermes.yaml",
+};
+
+type RunOpenshell = (
+ args: string[],
+ // The runner accepts a wider options shape; we only set ignoreError + stdio
+ // here, so erase the type at the boundary to keep this module free of the
+ // runner.ts internals.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ opts: any,
+) => { status: number | null; stderr?: string | Buffer | null; stdout?: string | Buffer | null };
+
+type TokenDefShape = { name: string; providerType?: string; token: string | null };
+
+/** Discovered bridge profile for one channel/agent, parsed from its profile YAML. */
+export interface MessagingBridgeProfile {
+ readonly channelId: string;
+ readonly agent: MessagingAgentId;
+ readonly profilePath: string;
+ /** OpenShell profile id (`provider create --type `). */
+ readonly profileId: string;
+ /** Injectable credential env var the gateway mints + the L7 proxy injects. */
+ readonly credentialKey: string;
+ /** Credential-refresh strategy (OpenShell kebab-case, e.g. google-service-account-jwt). */
+ readonly strategy: string;
+ /** OAuth scope(s) declared in the profile's refresh block. */
+ readonly scopes: readonly string[];
+ /** Material names the profile marks `secret: true` (ingested through --secret-material-env). */
+ readonly secretMaterialKeys: readonly string[];
+ /** Env var holding the pasted secret material (the channel's primary required secret). */
+ readonly sourceSecretEnv: string;
+}
+
+export interface ListMessagingBridgeProfilesDeps {
+ readonly root?: string;
+ readonly manifests?: readonly ChannelManifest[];
+ readonly existsSync?: (file: string) => boolean;
+ readonly readFileSync?: (file: string) => string;
+}
+
+export interface MessagingBridgeSecretResolveDeps {
+ readonly getCredential: (envKey: string) => string | null;
+ readonly env?: NodeJS.ProcessEnv | Record;
+ readonly normalizeCredentialValue?: (value: unknown) => string;
+}
+
+export interface CollectMessagingBridgeTokenDefsInput extends MessagingBridgeSecretResolveDeps {
+ readonly sandboxName: string;
+ readonly enabledChannels: readonly string[] | null;
+ readonly disabledChannelNames: ReadonlySet;
+ /** Injected for tests; defaults to convention discovery. */
+ readonly profiles?: readonly MessagingBridgeProfile[];
+}
+
+export interface EnsureMessagingBridgeProfilesDeps {
+ readonly root: string;
+ readonly runOpenshell: RunOpenshell;
+ readonly redact: (input: string) => string;
+ readonly log?: (message?: string) => void;
+ readonly exit?: (code?: number) => never;
+ readonly profiles?: readonly MessagingBridgeProfile[];
+}
+
+export interface ConfigureMessagingBridgeRefreshesDeps extends MessagingBridgeSecretResolveDeps {
+ readonly runOpenshell: RunOpenshell;
+ readonly redact: (input: string) => string;
+ readonly log?: (message?: string) => void;
+ readonly profiles?: readonly MessagingBridgeProfile[];
+}
+
+// Result of gateway-refresh configuration. `ok:false` when a bridge token def is
+// present but minting could not be configured, so the caller fails onboarding
+// instead of leaving the channel able to receive but not reply.
+export type MessagingBridgeRefreshResult = { ok: boolean; reason?: string };
+
+function bufferOrStringToText(value: string | Buffer | null | undefined): string {
+ if (typeof value === "string") return value;
+ if (value && typeof (value as Buffer).toString === "function")
+ return (value as Buffer).toString();
+ return "";
+}
+
+function isSafeChannelId(value: string): boolean {
+ return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(value);
+}
+
+/** Co-located provider-profile path, twin of channel policy's `/policy/.yaml`. */
+export function channelProviderProfilePath(
+ root: string,
+ channelId: string,
+ agent: MessagingAgentId,
+): string | null {
+ if (!isSafeChannelId(channelId)) return null;
+ return path.join(
+ root,
+ ...CHANNELS_SUBPATH,
+ channelId,
+ "provider-profile",
+ PROVIDER_PROFILE_FILE_BY_AGENT[agent],
+ );
+}
+
+function primarySecretEnv(manifest: ChannelManifest): string | null {
+ const input = manifest.inputs.find(
+ (entry): entry is ChannelSecretInputSpec => entry.kind === "secret" && entry.required,
+ );
+ return input?.envKey ?? null;
+}
+
+function parseProfileYaml(
+ content: string,
+): Omit | null {
+ let doc: Record | null;
+ try {
+ doc = YAML.parse(content) as Record | null;
+ } catch {
+ return null;
+ }
+ const profileId = doc?.id;
+ if (typeof profileId !== "string" || !profileId) return null;
+ const credentials = Array.isArray(doc?.credentials) ? doc?.credentials : null;
+ const credential = credentials?.[0] as Record | undefined;
+ if (!credential) return null;
+ const envVars = Array.isArray(credential.env_vars) ? credential.env_vars : [];
+ const credentialKey = typeof envVars[0] === "string" ? envVars[0] : null;
+ const refresh = credential.refresh as Record | undefined;
+ if (!credentialKey || !refresh) return null;
+ const strategy = refresh.strategy;
+ if (typeof strategy !== "string" || !strategy) return null;
+ const scopes = Array.isArray(refresh.scopes)
+ ? refresh.scopes.filter((s): s is string => typeof s === "string")
+ : [];
+ const material = Array.isArray(refresh.material) ? refresh.material : [];
+ const secretMaterialKeys = material
+ .filter(
+ (m): m is { name: string; secret: true } =>
+ !!m &&
+ (m as { secret?: unknown }).secret === true &&
+ typeof (m as { name?: unknown }).name === "string",
+ )
+ .map((m) => m.name);
+ return { profileId, credentialKey, strategy, scopes, secretMaterialKeys };
+}
+
+/**
+ * Discover the bridge provider profiles by convention: every channel manifest
+ * whose co-located `provider-profile/.yaml` exists and parses. Injectable
+ * for tests; defaults to the built-in registry + real filesystem.
+ */
+export function listMessagingBridgeProfiles(
+ deps: ListMessagingBridgeProfilesDeps = {},
+): MessagingBridgeProfile[] {
+ const root = deps.root ?? ROOT;
+ const existsSync = deps.existsSync ?? ((file: string) => fs.existsSync(file));
+ const readFileSync = deps.readFileSync ?? ((file: string) => fs.readFileSync(file, "utf-8"));
+ const manifests = deps.manifests ?? createBuiltInChannelManifestRegistry().list();
+
+ const profiles: MessagingBridgeProfile[] = [];
+ for (const manifest of manifests) {
+ const sourceSecretEnv = primarySecretEnv(manifest);
+ if (!sourceSecretEnv) continue;
+ for (const agent of manifest.supportedAgents) {
+ const profilePath = channelProviderProfilePath(root, manifest.id, agent);
+ if (!profilePath || !existsSync(profilePath)) continue;
+ const parsed = parseProfileYaml(readFileSync(profilePath));
+ if (!parsed) continue;
+ profiles.push({ channelId: manifest.id, agent, profilePath, sourceSecretEnv, ...parsed });
+ }
+ }
+ return profiles;
+}
+
+/**
+ * Resolve the pasted secret material with the same order the rest of onboarding
+ * uses: the credential store first, then the injected env map (mirrors the Brave
+ * key resolution). Using `getCredential` alone misses non-interactive runs where
+ * the value arrives through the passed-in env.
+ */
+function resolveBridgeSecret(
+ envKey: string,
+ deps: MessagingBridgeSecretResolveDeps,
+): string | null {
+ const fromCredential = deps.getCredential(envKey);
+ if (fromCredential) return fromCredential;
+ if (deps.env && deps.normalizeCredentialValue) {
+ const fromEnv = deps.normalizeCredentialValue(deps.env[envKey]);
+ if (fromEnv) return fromEnv;
+ }
+ return null;
+}
+
+function bridgeProfilesForTokenDefs(
+ tokenDefs: readonly TokenDefShape[],
+ profiles: readonly MessagingBridgeProfile[],
+): MessagingBridgeProfile[] {
+ const presentProfileIds = new Set(
+ tokenDefs.filter(({ token }) => Boolean(token)).map(({ providerType }) => providerType),
+ );
+ return profiles.filter((profile) => presentProfileIds.has(profile.profileId));
+}
+
+/** Gateway-minted bridge provider name for a channel (sandbox-scoped). */
+function bridgeProviderNameFor(sandboxName: string, channelId: string): string {
+ return `${sandboxName}-${channelId}-bridge`;
+}
+
+/**
+ * Build the messaging token definitions for every enabled bridge channel whose
+ * source secret was captured. Mirrors how the Brave provider is pushed in
+ * messaging-prep: the value is a non-empty sentinel (overwritten by the first
+ * refresh) and the real material is supplied separately by
+ * {@link configureMessagingBridgeRefreshes}.
+ */
+export function collectMessagingBridgeTokenDefs(
+ input: CollectMessagingBridgeTokenDefsInput,
+): { name: string; envKey: string; token: string; providerType: string }[] {
+ const profiles = input.profiles ?? listMessagingBridgeProfiles();
+ const defs: { name: string; envKey: string; token: string; providerType: string }[] = [];
+ for (const profile of profiles) {
+ if (input.disabledChannelNames.has(profile.channelId)) continue;
+ if (input.enabledChannels != null && !input.enabledChannels.includes(profile.channelId))
+ continue;
+ const secret = resolveBridgeSecret(profile.sourceSecretEnv, input);
+ if (!secret) continue;
+ defs.push({
+ name: bridgeProviderNameFor(input.sandboxName, profile.channelId),
+ envKey: profile.credentialKey,
+ token: MESSAGING_BRIDGE_PENDING_VALUE,
+ providerType: profile.profileId,
+ });
+ }
+ return defs;
+}
+
+/**
+ * Gateway-minted bridge provider name(s) for a channel — the providers
+ * `channels remove` must tear down. A bridge-backed channel has no
+ * channelTokenKeys, so these would otherwise be left dangling (still minting and
+ * rotating a token for a removed channel). `profiles` is injectable for tests;
+ * defaults to convention discovery.
+ */
+export function bridgeProviderNamesForChannel(
+ sandboxName: string,
+ channelName: string,
+ profiles: readonly MessagingBridgeProfile[] = listMessagingBridgeProfiles(),
+): string[] {
+ return [
+ ...new Set(
+ profiles
+ .filter((profile) => profile.channelId === channelName)
+ .map((profile) => bridgeProviderNameFor(sandboxName, profile.channelId)),
+ ),
+ ];
+}
+
+/**
+ * Source-secret env var(s) a channel's bridge profile(s) require — for naming
+ * the missing env var in enable-time error messages.
+ */
+export function bridgeSecretEnvsForChannel(
+ channelName: string,
+ profiles: readonly MessagingBridgeProfile[] = listMessagingBridgeProfiles(),
+): string[] {
+ return [
+ ...new Set(
+ profiles
+ .filter((profile) => profile.channelId === channelName)
+ .map((profile) => profile.sourceSecretEnv),
+ ),
+ ];
+}
+
+/**
+ * Register each active bridge provider profile with OpenShell before providers
+ * are created (they are created with `--type `). Idempotent: tolerates
+ * OpenShell reporting the custom profile already exists. Self-gates when no bridge
+ * token def is present.
+ */
+export function ensureMessagingBridgeProfiles(
+ tokenDefs: readonly TokenDefShape[],
+ deps: EnsureMessagingBridgeProfilesDeps,
+): void {
+ const profiles = deps.profiles ?? listMessagingBridgeProfiles({ root: deps.root });
+ const active = bridgeProfilesForTokenDefs(tokenDefs, profiles);
+ if (active.length === 0) return;
+
+ const errorLog = deps.log ?? console.error;
+ const exit = deps.exit ?? ((code?: number) => process.exit(code));
+
+ for (const profile of active) {
+ const result = deps.runOpenshell(
+ ["provider", "profile", "import", "--file", profile.profilePath],
+ { ignoreError: true, stdio: ["ignore", "pipe", "pipe"] },
+ );
+ if (result.status === 0) continue;
+
+ const rawDiagnostic = `${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`;
+ if (/already exists/i.test(rawDiagnostic)) continue;
+
+ const diagnostic = compactText(deps.redact(rawDiagnostic));
+ errorLog(
+ `\n ✗ Failed to register the ${profile.channelId} bridge provider profile with OpenShell.`,
+ );
+ if (diagnostic) errorLog(` ${diagnostic.slice(0, 500)}`);
+ errorLog(" Update OpenShell with scripts/install-openshell.sh and re-run onboarding.");
+ exit(result.status || 1);
+ return;
+ }
+}
+
+function buildRefreshMaterial(
+ profile: MessagingBridgeProfile,
+ secret: string,
+):
+ | { ok: true; material: { key: string; value: string }[]; secretKeys: string[] }
+ | { ok: false; reason: string } {
+ if (profile.strategy === "google-service-account-jwt") {
+ let parsed: Record;
+ try {
+ parsed = JSON.parse(secret) as Record;
+ } catch {
+ return { ok: false, reason: "service account JSON could not be parsed" };
+ }
+ const clientEmail = parsed.client_email;
+ const privateKey = parsed.private_key;
+ if (
+ typeof clientEmail !== "string" ||
+ !clientEmail ||
+ typeof privateKey !== "string" ||
+ !privateKey
+ ) {
+ return { ok: false, reason: "service account JSON missing client_email/private_key" };
+ }
+ const material = [
+ { key: "client_email", value: clientEmail },
+ { key: "private_key", value: privateKey },
+ ];
+ // Scope comes from the profile's declared refresh scopes (single source of truth).
+ if (profile.scopes[0]) material.push({ key: "scope", value: profile.scopes[0] });
+ const secretKeys =
+ profile.secretMaterialKeys.length > 0 ? [...profile.secretMaterialKeys] : ["private_key"];
+ return { ok: true, material, secretKeys };
+ }
+ return { ok: false, reason: `unsupported refresh strategy '${profile.strategy}'` };
+}
+
+/**
+ * Configure gateway-side credential refresh for every active bridge provider:
+ * the gateway mints (and rotates) the token from the pasted secret material. Must
+ * run AFTER the providers are created. Fail-closed: when a bridge token def is
+ * present but minting cannot be configured, returns { ok:false } so the caller
+ * aborts rather than leaving the channel able to receive but not reply. The secret
+ * material is never logged.
+ */
+export function configureMessagingBridgeRefreshes(
+ tokenDefs: readonly TokenDefShape[],
+ deps: ConfigureMessagingBridgeRefreshesDeps,
+): MessagingBridgeRefreshResult {
+ const profiles = deps.profiles ?? listMessagingBridgeProfiles();
+ const active = bridgeProfilesForTokenDefs(tokenDefs, profiles);
+ if (active.length === 0) return { ok: true };
+
+ const warn = deps.log ?? console.error;
+ for (const profile of active) {
+ const bridge = tokenDefs.find(
+ ({ providerType, token }) => providerType === profile.profileId && Boolean(token),
+ );
+ if (!bridge) continue;
+
+ const secret = resolveBridgeSecret(profile.sourceSecretEnv, deps);
+ if (!secret) {
+ warn(
+ `\n ✗ ${profile.channelId} bridge: secret material unavailable; cannot configure gateway token minting.`,
+ );
+ return { ok: false, reason: "secret material unavailable" };
+ }
+
+ const built = buildRefreshMaterial(profile, secret);
+ if (!built.ok) {
+ warn(
+ `\n ✗ ${profile.channelId} bridge: ${built.reason}; cannot configure gateway token minting.`,
+ );
+ return { ok: false, reason: built.reason };
+ }
+
+ // OpenShell reads secret refresh material from its own process environment,
+ // so private keys never appear in argv. Reuse the same ephemeral variable
+ // names safely: each profile is configured by a separate child process.
+ const secretKeys = new Set(built.secretKeys);
+ const materialArgs: string[] = [];
+ const secretMaterialEnv: NodeJS.ProcessEnv = {};
+ let secretIndex = 0;
+ for (const { key, value } of built.material) {
+ if (secretKeys.has(key)) {
+ const envName = `MESSAGING_BRIDGE_SECRET_${secretIndex}`;
+ secretIndex += 1;
+ secretMaterialEnv[envName] = value;
+ materialArgs.push("--secret-material-env", `${key}=${envName}`);
+ continue;
+ }
+ materialArgs.push("--material", `${key}=${value}`);
+ }
+ const result = deps.runOpenshell(
+ [
+ "provider",
+ "refresh",
+ "configure",
+ "--credential-key",
+ profile.credentialKey,
+ "--strategy",
+ profile.strategy,
+ ...materialArgs,
+ bridge.name,
+ ],
+ {
+ env: secretMaterialEnv,
+ ignoreError: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ },
+ );
+ if (result.status === 0) continue;
+
+ // Redact before logging — never echo secret material.
+ const diagnostic = compactText(
+ deps.redact(`${bufferOrStringToText(result.stderr)} ${bufferOrStringToText(result.stdout)}`),
+ );
+ warn(
+ `\n ✗ ${profile.channelId} bridge: failed to configure gateway token minting for '${bridge.name}'.`,
+ );
+ if (diagnostic) warn(` ${diagnostic.slice(0, 500)}`);
+ warn(" Outbound replies for this channel will not authenticate until this is resolved.");
+ return {
+ ok: false,
+ reason: diagnostic || `provider refresh configure exited with status ${result.status}`,
+ };
+ }
+ return { ok: true };
+}
diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts
index 886d348996..129cf5ea69 100644
--- a/src/lib/onboard/messaging-prep.test.ts
+++ b/src/lib/onboard/messaging-prep.test.ts
@@ -73,6 +73,70 @@ describe("prepareCreateSandboxMessaging", () => {
);
});
+ it("reuses an existing gateway bridge provider when the bridge secret is not resolvable", () => {
+ // Deferred rebuild in a fresh process: the pasted secret is env-only and
+ // gone, so no bridge token def exists — but the gateway still durably
+ // holds the refresh material, so the provider only needs re-attaching.
+ const providerExistsInGateway = vi.fn((name: string) => name === "demo-googlechat-bridge");
+
+ const result = prepareCreateSandboxMessaging(
+ createInput({
+ enabledChannels: ["googlechat"],
+ providerExistsInGateway,
+ }),
+ );
+
+ expect(result.messagingTokenDefs.some((def) => def.name === "demo-googlechat-bridge")).toBe(
+ false,
+ );
+ expect(result.reusableMessagingProviders).toContain("demo-googlechat-bridge");
+ expect(result.reusableMessagingChannels).toContain("googlechat");
+ expect(providerExistsInGateway).toHaveBeenCalledWith("demo-googlechat-bridge");
+ });
+
+ it("routes the bridge through upsert instead of reuse when the secret is resolvable", () => {
+ const result = prepareCreateSandboxMessaging(
+ createInput({
+ enabledChannels: ["googlechat"],
+ env: {
+ GOOGLECHAT_SERVICE_ACCOUNT: JSON.stringify({
+ client_email: "bot@p.iam.gserviceaccount.com",
+ private_key: "fake-test-private-key-material",
+ }),
+ },
+ providerExistsInGateway: () => true,
+ }),
+ );
+
+ const def = result.messagingTokenDefs.find((d) => d.name === "demo-googlechat-bridge");
+ expect(def?.token).toBeTruthy();
+ expect(result.reusableMessagingProviders).not.toContain("demo-googlechat-bridge");
+ });
+
+ it("does not reuse a bridge provider that is absent from the gateway", () => {
+ const result = prepareCreateSandboxMessaging(
+ createInput({
+ enabledChannels: ["googlechat"],
+ providerExistsInGateway: () => false,
+ }),
+ );
+
+ expect(result.reusableMessagingProviders).toEqual([]);
+ expect(result.reusableMessagingChannels).toEqual([]);
+ });
+
+ it("does not reuse the bridge provider of a disabled channel", () => {
+ const result = prepareCreateSandboxMessaging(
+ createInput({
+ enabledChannels: ["googlechat"],
+ disabledChannels: ["googlechat"],
+ providerExistsInGateway: () => true,
+ }),
+ );
+
+ expect(result.reusableMessagingProviders).toEqual([]);
+ });
+
it("reports missing Brave API keys before registering extra placeholder providers", () => {
const registerExtraPlaceholderProviders = vi.fn(() => ["BRAVE_API_KEY_AGENT_A"]);
diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts
index fa9079a975..1699f68e55 100644
--- a/src/lib/onboard/messaging-prep.ts
+++ b/src/lib/onboard/messaging-prep.ts
@@ -6,6 +6,10 @@ import * as webSearch from "../inference/web-search";
import { listMessagingCredentialMetadata } from "../messaging/channels";
import { type ChannelDef, getChannelTokenKeys } from "../sandbox/channels";
import * as braveProviderProfile from "./brave-provider-profile";
+import {
+ bridgeProviderNamesForChannel,
+ collectMessagingBridgeTokenDefs,
+} from "./messaging-bridge-provider";
export type NamedMessagingChannel = { name: string } & ChannelDef;
@@ -110,6 +114,23 @@ export function prepareCreateSandboxMessaging(
});
}
+ // Messaging bridge providers: any channel that mints its outbound token
+ // gateway-side (declared by a co-located provider-profile YAML) registers a
+ // refresh-minted provider so the gateway mints the token (secret stays
+ // gateway-side) and the L7 proxy injects it. The credential value is a sentinel
+ // (minted by refresh, configured post-create in onboard's
+ // upsertMessagingProviders wrapper). Today only Google Chat uses this.
+ messagingTokenDefs.push(
+ ...collectMessagingBridgeTokenDefs({
+ sandboxName: input.sandboxName,
+ getCredential: input.getCredential,
+ env: input.env,
+ normalizeCredentialValue: input.normalizeCredentialValue,
+ enabledChannels: input.enabledChannels,
+ disabledChannelNames,
+ }),
+ );
+
const extraPlaceholderKeys = input.registerExtraPlaceholderProviders(
input.sandboxName,
messagingTokenDefs,
@@ -131,6 +152,24 @@ export function prepareCreateSandboxMessaging(
}
}
+ // Bridge channels have no token def at all when their env-only secret is
+ // gone (fresh process), so the envKey loop above misses them. The gateway
+ // still holds the refresh material — reuse the provider by name instead.
+ if (input.enabledChannels != null) {
+ for (const channel of input.enabledChannels) {
+ if (disabledChannelNames.has(channel)) continue;
+ for (const name of bridgeProviderNamesForChannel(input.sandboxName, channel)) {
+ if (messagingTokenDefs.some((def) => def.name === name && def.token)) continue;
+ if (reusableMessagingProviders.includes(name)) continue;
+ if (!input.providerExistsInGateway(name)) continue;
+ reusableMessagingProviders.push(name);
+ if (!reusableMessagingChannels.includes(channel)) {
+ reusableMessagingChannels.push(channel);
+ }
+ }
+ }
+ }
+
return {
disabledChannelNames,
messagingTokenDefs,
diff --git a/src/lib/onboard/providers.ts b/src/lib/onboard/providers.ts
index b9e6125140..c2a80df7e6 100644
--- a/src/lib/onboard/providers.ts
+++ b/src/lib/onboard/providers.ts
@@ -4,8 +4,8 @@
//
// Provider metadata, lookup helpers, and gateway provider CRUD.
-const { redact } = require("../runner");
-const { normalizeCredentialValue } = require("../credentials/store");
+const { redact, ROOT } = require("../runner");
+const { normalizeCredentialValue, getCredential } = require("../credentials/store");
const {
DEFAULT_CLOUD_MODEL,
DEFAULT_HERMES_PROVIDER_MODEL,
@@ -460,6 +460,31 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env, _runOpenshell,
* @returns {string[]} Provider names that were upserted.
*/
function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) {
+ // Provider creation order. Bridges (e.g. Google Chat) need two steps bracketing
+ // the uniform create loop, ordered around `provider create`:
+ //
+ // ensureMessagingBridgeProfiles <- BEFORE loop: import the profile
+ // provider profile import (must exist before `provider create`)
+ // |
+ // +----v-------------------------------------------------+
+ // | for (tokenDef of tokenDefs) <- THE LOOP |
+ // | upsertProvider(name, providerType || "generic") | bridge created
+ // | . slack -> --type generic | with a sentinel
+ // | . googlechat -> --type google-chat-bridge | token
+ // +----+-------------------------------------------------+
+ // |
+ // configureMessagingBridgeRefreshes <- AFTER loop: refresh mints the real
+ // provider refresh configure token, overwriting the sentinel
+ //
+ // A channel is a bridge by the PRESENCE of a co-located
+ // channels//provider-profile/.yaml (not a flag inside it); both
+ // bracket steps self-gate when no bridge token def is present.
+ const messagingBridgeProvider = require("./messaging-bridge-provider");
+ messagingBridgeProvider.ensureMessagingBridgeProfiles(tokenDefs, {
+ root: ROOT,
+ runOpenshell: _runOpenshell,
+ redact,
+ });
const upserted = [];
const failures = [];
for (const { name, envKey, token, providerType } of tokenDefs) {
@@ -486,6 +511,30 @@ function upsertMessagingProviders(tokenDefs, _runOpenshell, options = {}) {
if (failures.length > 0) {
throw new Error(failures.join("; "));
}
+ // Gateway-side token minting is configured AFTER the providers exist (best-effort,
+ // self-gates without a bridge token def). Secret material stays gateway-side —
+ // never written into the sandbox.
+ const refreshResult = messagingBridgeProvider.configureMessagingBridgeRefreshes(tokenDefs, {
+ runOpenshell: _runOpenshell,
+ redact,
+ getCredential,
+ env: process.env,
+ normalizeCredentialValue,
+ });
+ // Fail-closed: an active bridge channel whose gateway token minting was not
+ // configured can receive webhooks but cannot authenticate outbound replies.
+ // Surface it instead of reporting a fully-configured channel (bestEffort/rollback
+ // paths report residual work by throwing; the normal path exits like a failed
+ // provider upsert above).
+ if (refreshResult && !refreshResult.ok) {
+ if (options.bestEffort) {
+ throw new Error("Failed to configure gateway token minting for a messaging bridge.");
+ }
+ console.error(
+ "\n ✗ Gateway token minting for a messaging bridge was not configured; aborting.",
+ );
+ process.exit(1);
+ }
return upserted;
}
diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts
index 5097262af2..d05a7a4c69 100644
--- a/src/lib/sandbox/channels.test.ts
+++ b/src/lib/sandbox/channels.test.ts
@@ -16,7 +16,7 @@ import {
} from "./channels";
describe("sandbox-channels KNOWN_CHANNELS", () => {
- it("covers telegram, discord, wechat, slack, whatsapp, and teams", () => {
+ it("covers telegram, discord, wechat, slack, whatsapp, teams, and googlechat", () => {
expect(knownChannelNames()).toEqual([
"telegram",
"discord",
@@ -24,6 +24,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => {
"slack",
"whatsapp",
"teams",
+ "googlechat",
]);
});
@@ -194,6 +195,7 @@ describe("sandbox-channels listChannels", () => {
"slack",
"whatsapp",
"teams",
+ "googlechat",
]);
const telegram = list.find((c) => c.name === "telegram");
expect(telegram?.envKey).toBe("TELEGRAM_BOT_TOKEN");
diff --git a/src/lib/state/openclaw-managed-extensions.test.ts b/src/lib/state/openclaw-managed-extensions.test.ts
index c6ce0a7a5f..7aa0ecc49f 100644
--- a/src/lib/state/openclaw-managed-extensions.test.ts
+++ b/src/lib/state/openclaw-managed-extensions.test.ts
@@ -25,6 +25,7 @@ const EXPECTED_MANAGED_EXTENSIONS = [
"slack",
"whatsapp",
"msteams",
+ "googlechat",
] as const;
describe("OpenClaw managed extension policy", () => {
diff --git a/src/lib/tunnel/googlechat-webhook-lifecycle.test.ts b/src/lib/tunnel/googlechat-webhook-lifecycle.test.ts
new file mode 100644
index 0000000000..a276c3e136
--- /dev/null
+++ b/src/lib/tunnel/googlechat-webhook-lifecycle.test.ts
@@ -0,0 +1,33 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+import {
+ googlechatWebhookTunnelPidDir,
+ stopGooglechatWebhookTunnel,
+} from "./googlechat-webhook-lifecycle";
+
+describe("Google Chat webhook tunnel lifecycle", () => {
+ it("stops the sandbox-scoped cloudflared process and route proxy", () => {
+ const stopCloudflared = vi.fn();
+ const stopGooglechatWebhookProxy = vi.fn();
+ const pidDir = stopGooglechatWebhookTunnel("alpha", {
+ services: {
+ resolveServicePidDir: ({ sandboxName } = {}) =>
+ `/tmp/nemoclaw-services-${sandboxName ?? "default"}`,
+ stopCloudflared,
+ },
+ webhookProxy: { stopGooglechatWebhookProxy },
+ });
+
+ expect(pidDir).toBe("/tmp/nemoclaw-services-alpha-googlechat");
+ expect(stopCloudflared).toHaveBeenCalledWith({ pidDir });
+ expect(stopGooglechatWebhookProxy).toHaveBeenCalledWith(pidDir);
+ });
+
+ it("derives a separate state directory from the normal tunnel", () => {
+ expect(googlechatWebhookTunnelPidDir("/tmp/nemoclaw-services-alpha")).toBe(
+ "/tmp/nemoclaw-services-alpha-googlechat",
+ );
+ });
+});
diff --git a/src/lib/tunnel/googlechat-webhook-lifecycle.ts b/src/lib/tunnel/googlechat-webhook-lifecycle.ts
new file mode 100644
index 0000000000..2d4bcaad7f
--- /dev/null
+++ b/src/lib/tunnel/googlechat-webhook-lifecycle.ts
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+type TunnelServices = Pick;
+type WebhookProxy = Pick;
+
+export type GooglechatWebhookLifecycleDeps = {
+ readonly services?: TunnelServices;
+ readonly webhookProxy?: WebhookProxy;
+};
+
+export function googlechatWebhookTunnelPidDir(servicePidDir: string): string {
+ return `${servicePidDir}-googlechat`;
+}
+
+export function stopGooglechatWebhookTunnel(
+ sandboxName: string,
+ deps: GooglechatWebhookLifecycleDeps = {},
+): string {
+ const services = deps.services ?? (require("./services") as TunnelServices);
+ const webhookProxy = deps.webhookProxy ?? (require("./googlechat-webhook-proxy") as WebhookProxy);
+ const pidDir = googlechatWebhookTunnelPidDir(services.resolveServicePidDir({ sandboxName }));
+ services.stopCloudflared({ pidDir });
+ webhookProxy.stopGooglechatWebhookProxy(pidDir);
+ return pidDir;
+}
diff --git a/src/lib/tunnel/googlechat-webhook-proxy.test.ts b/src/lib/tunnel/googlechat-webhook-proxy.test.ts
new file mode 100644
index 0000000000..c9eb8b6982
--- /dev/null
+++ b/src/lib/tunnel/googlechat-webhook-proxy.test.ts
@@ -0,0 +1,119 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { mkdtempSync, rmSync, statSync } from "node:fs";
+import { createServer, type Server } from "node:http";
+import type { AddressInfo } from "node:net";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ readGooglechatWebhookProxyState,
+ startGooglechatWebhookProxy,
+ stopGooglechatWebhookProxy,
+} from "./googlechat-webhook-proxy";
+
+const cleanupDirs = new Set();
+const cleanupServers = new Set();
+
+afterEach(async () => {
+ for (const server of cleanupServers) {
+ await new Promise((resolve) => server.close(() => resolve()));
+ }
+ cleanupServers.clear();
+ for (const dir of cleanupDirs) {
+ stopGooglechatWebhookProxy(dir);
+ rmSync(dir, { recursive: true, force: true });
+ }
+ cleanupDirs.clear();
+});
+
+async function listen(server: Server): Promise {
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => resolve());
+ });
+ const address = server.address();
+ expect(address).not.toBeNull();
+ expect(typeof address).not.toBe("string");
+ return (address as AddressInfo).port;
+}
+
+describe("Google Chat webhook route proxy", () => {
+ it("forwards only POST /googlechat and denies dashboard or control routes", async () => {
+ const received: Array<{ method?: string; url?: string; body: string }> = [];
+ const upstream = createServer((request, response) => {
+ const chunks: Buffer[] = [];
+ request.on("data", (chunk: Buffer) => chunks.push(chunk));
+ request.on("end", () => {
+ received.push({
+ method: request.method,
+ url: request.url,
+ body: Buffer.concat(chunks).toString("utf8"),
+ });
+ response.writeHead(202, { "content-type": "application/json" });
+ response.end('{"accepted":true}');
+ });
+ });
+ cleanupServers.add(upstream);
+ const upstreamPort = await listen(upstream);
+ const pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-googlechat-proxy-"));
+ cleanupDirs.add(pidDir);
+
+ const proxyPort = await startGooglechatWebhookProxy(pidDir, upstreamPort);
+ const webhook = await fetch(`http://127.0.0.1:${String(proxyPort)}/googlechat?key=value`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: '{"type":"MESSAGE"}',
+ });
+ expect(webhook.status).toBe(202);
+ expect(await webhook.json()).toEqual({ accepted: true });
+ expect(received).toEqual([
+ {
+ method: "POST",
+ url: "/googlechat?key=value",
+ body: '{"type":"MESSAGE"}',
+ },
+ ]);
+
+ for (const [path, method] of [
+ ["/", "POST"],
+ ["/health", "POST"],
+ ["/ws", "POST"],
+ ["/googlechat", "GET"],
+ ] as const) {
+ const response = await fetch(`http://127.0.0.1:${String(proxyPort)}${path}`, { method });
+ expect(response.status, `${method} ${path}`).toBe(404);
+ }
+ expect(received).toHaveLength(1);
+
+ const state = readGooglechatWebhookProxyState(pidDir);
+ expect(state).toEqual({ running: true, port: proxyPort, upstreamPort });
+ expect(statSync(join(pidDir, "nemoclaw-googlechat-webhook-proxy.pid")).mode & 0o777).toBe(
+ 0o600,
+ );
+ expect(statSync(join(pidDir, "nemoclaw-googlechat-webhook-proxy.json")).mode & 0o777).toBe(
+ 0o600,
+ );
+ });
+
+ it("rejects oversized webhook bodies before they reach the dashboard", async () => {
+ let upstreamRequests = 0;
+ const upstream = createServer((_request, response) => {
+ upstreamRequests += 1;
+ response.end("unexpected");
+ });
+ cleanupServers.add(upstream);
+ const upstreamPort = await listen(upstream);
+ const pidDir = mkdtempSync(join(tmpdir(), "nemoclaw-googlechat-proxy-"));
+ cleanupDirs.add(pidDir);
+ const proxyPort = await startGooglechatWebhookProxy(pidDir, upstreamPort);
+
+ const response = await fetch(`http://127.0.0.1:${String(proxyPort)}/googlechat`, {
+ method: "POST",
+ body: Buffer.alloc(1024 * 1024 + 1, 1),
+ });
+ expect(response.status).toBe(413);
+ expect(upstreamRequests).toBe(0);
+ });
+});
diff --git a/src/lib/tunnel/googlechat-webhook-proxy.ts b/src/lib/tunnel/googlechat-webhook-proxy.ts
new file mode 100644
index 0000000000..1dc6c0961d
--- /dev/null
+++ b/src/lib/tunnel/googlechat-webhook-proxy.ts
@@ -0,0 +1,284 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { execFileSync, spawn } from "node:child_process";
+import {
+ chmodSync,
+ closeSync,
+ constants,
+ existsSync,
+ fchmodSync,
+ mkdirSync,
+ openSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { join } from "node:path";
+import { buildSubprocessEnv } from "../subprocess-env";
+
+const PROCESS_MARKER = "nemoclaw-googlechat-webhook-proxy";
+const PID_FILE = `${PROCESS_MARKER}.pid`;
+const STATE_FILE = `${PROCESS_MARKER}.json`;
+const LOG_FILE = `${PROCESS_MARKER}.log`;
+const START_TIMEOUT_MS = 5000;
+
+export type GooglechatWebhookProxyState =
+ | { readonly running: false; readonly port: null; readonly upstreamPort: null }
+ | { readonly running: true; readonly port: number; readonly upstreamPort: number };
+
+const PROXY_SCRIPT = String.raw`
+"use strict";
+const fs = require("node:fs");
+const http = require("node:http");
+
+const upstreamPort = Number(process.env.NEMOCLAW_GOOGLECHAT_PROXY_UPSTREAM_PORT);
+const stateFile = process.env.NEMOCLAW_GOOGLECHAT_PROXY_STATE_FILE;
+const maxBodyBytes = 1024 * 1024;
+const hopByHop = new Set([
+ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
+ "proxy-connection", "te", "trailer", "transfer-encoding", "upgrade",
+]);
+
+if (!Number.isSafeInteger(upstreamPort) || upstreamPort < 1 || upstreamPort > 65535 || !stateFile) {
+ process.stderr.write("invalid Google Chat webhook proxy configuration\n");
+ process.exit(1);
+}
+
+function filteredHeaders(headers) {
+ const filtered = {};
+ for (const [name, value] of Object.entries(headers)) {
+ if (!hopByHop.has(name.toLowerCase()) && value !== undefined) filtered[name] = value;
+ }
+ return filtered;
+}
+
+function send(res, status, message) {
+ if (res.headersSent) return res.destroy();
+ res.writeHead(status, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
+ res.end(message);
+}
+
+function forward(req, res, body) {
+ const headers = filteredHeaders(req.headers);
+ headers.host = "127.0.0.1:" + String(upstreamPort);
+ headers["content-length"] = String(body.length);
+ const upstream = http.request({
+ hostname: "127.0.0.1",
+ port: upstreamPort,
+ method: "POST",
+ path: req.url,
+ headers,
+ agent: false,
+ }, (upstreamRes) => {
+ res.writeHead(upstreamRes.statusCode || 502, filteredHeaders(upstreamRes.headers));
+ upstreamRes.pipe(res);
+ });
+ upstream.setTimeout(15000, () => upstream.destroy(new Error("upstream timeout")));
+ upstream.on("error", () => send(res, 502, "Bad Gateway\n"));
+ upstream.end(body);
+}
+
+const server = http.createServer((req, res) => {
+ let pathname = "";
+ try {
+ pathname = new URL(req.url || "", "http://localhost").pathname;
+ } catch {}
+ if (req.method !== "POST" || pathname !== "/googlechat") {
+ req.resume();
+ return send(res, 404, "Not Found\n");
+ }
+
+ const declaredLength = Number(req.headers["content-length"] || 0);
+ if (declaredLength > maxBodyBytes) {
+ req.resume();
+ return send(res, 413, "Payload Too Large\n");
+ }
+
+ const chunks = [];
+ let received = 0;
+ let rejected = false;
+ req.on("data", (chunk) => {
+ received += chunk.length;
+ if (received > maxBodyBytes) {
+ rejected = true;
+ chunks.length = 0;
+ send(res, 413, "Payload Too Large\n");
+ return;
+ }
+ if (!rejected) chunks.push(chunk);
+ });
+ req.on("end", () => {
+ if (!rejected) forward(req, res, Buffer.concat(chunks));
+ });
+ req.on("error", () => send(res, 400, "Bad Request\n"));
+});
+
+server.requestTimeout = 15000;
+server.headersTimeout = 10000;
+server.keepAliveTimeout = 1000;
+server.on("clientError", (_error, socket) => socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"));
+server.on("upgrade", (_request, socket) => socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n"));
+server.listen(0, "127.0.0.1", () => {
+ const address = server.address();
+ if (!address || typeof address === "string") process.exit(1);
+ fs.writeFileSync(stateFile, JSON.stringify({ port: address.port, upstreamPort }) + "\n", {
+ encoding: "utf8",
+ flag: "wx",
+ mode: 0o600,
+ });
+});
+
+function shutdown() {
+ server.close(() => process.exit(0));
+ setTimeout(() => process.exit(0), 1000).unref();
+}
+process.on("SIGTERM", shutdown);
+process.on("SIGINT", shutdown);
+`;
+
+function validatePort(port: number, label: string): number {
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
+ throw new Error(`Invalid ${label}: ${String(port)}`);
+ }
+ return port;
+}
+
+function ensureStateDir(pidDir: string): void {
+ mkdirSync(pidDir, { recursive: true, mode: 0o700 });
+ chmodSync(pidDir, 0o700);
+}
+
+function readPositiveInteger(file: string): number | null {
+ try {
+ const value = Number(readFileSync(file, "utf8").trim());
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
+ } catch {
+ return null;
+ }
+}
+
+function processIsAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function processHasMarker(pid: number): boolean {
+ try {
+ return readFileSync(`/proc/${String(pid)}/cmdline`, "utf8").includes(PROCESS_MARKER);
+ } catch {
+ try {
+ return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ timeout: 1000,
+ }).includes(PROCESS_MARKER);
+ } catch {
+ return false;
+ }
+ }
+}
+
+function readProxyMetadata(file: string): { port: number; upstreamPort: number } | null {
+ try {
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
+ if (typeof parsed !== "object" || parsed === null) return null;
+ const port = Reflect.get(parsed, "port");
+ const upstreamPort = Reflect.get(parsed, "upstreamPort");
+ if (typeof port !== "number" || typeof upstreamPort !== "number") return null;
+ return {
+ port: validatePort(port, "Google Chat webhook proxy port"),
+ upstreamPort: validatePort(upstreamPort, "Google Chat webhook upstream port"),
+ };
+ } catch {
+ return null;
+ }
+}
+
+function removeProxyState(pidDir: string): void {
+ rmSync(join(pidDir, PID_FILE), { force: true });
+ rmSync(join(pidDir, STATE_FILE), { force: true });
+}
+
+function writePidFile(pidDir: string, pid: number): void {
+ const file = join(pidDir, PID_FILE);
+ const flags =
+ constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW ?? 0);
+ const fd = openSync(file, flags, 0o600);
+ try {
+ fchmodSync(fd, 0o600);
+ writeFileSync(fd, String(pid));
+ } finally {
+ closeSync(fd);
+ }
+}
+
+export function readGooglechatWebhookProxyState(pidDir: string): GooglechatWebhookProxyState {
+ const pid = readPositiveInteger(join(pidDir, PID_FILE));
+ const metadata = readProxyMetadata(join(pidDir, STATE_FILE));
+ if (pid === null || metadata === null || !processIsAlive(pid) || !processHasMarker(pid)) {
+ return { running: false, port: null, upstreamPort: null };
+ }
+ return { running: true, ...metadata };
+}
+
+export function stopGooglechatWebhookProxy(pidDir: string): void {
+ const pid = readPositiveInteger(join(pidDir, PID_FILE));
+ if (pid !== null && processIsAlive(pid) && processHasMarker(pid)) {
+ try {
+ process.kill(pid, "SIGTERM");
+ } catch {
+ // The proxy exited between validation and signaling.
+ }
+ }
+ removeProxyState(pidDir);
+}
+
+export async function startGooglechatWebhookProxy(
+ pidDir: string,
+ upstreamPortInput: number,
+): Promise {
+ const upstreamPort = validatePort(upstreamPortInput, "Google Chat webhook upstream port");
+ ensureStateDir(pidDir);
+
+ const existing = readGooglechatWebhookProxyState(pidDir);
+ if (existing.running && existing.upstreamPort === upstreamPort) return existing.port;
+ stopGooglechatWebhookProxy(pidDir);
+
+ const logFd = openSync(join(pidDir, LOG_FILE), "w", 0o600);
+ fchmodSync(logFd, 0o600);
+ const child = spawn(process.execPath, ["-e", PROXY_SCRIPT, PROCESS_MARKER], {
+ detached: true,
+ stdio: ["ignore", logFd, logFd],
+ env: buildSubprocessEnv({
+ NEMOCLAW_GOOGLECHAT_PROXY_STATE_FILE: join(pidDir, STATE_FILE),
+ NEMOCLAW_GOOGLECHAT_PROXY_UPSTREAM_PORT: String(upstreamPort),
+ }),
+ });
+ closeSync(logFd);
+ child.on("error", () => {});
+
+ if (child.pid === undefined) throw new Error("Google Chat webhook proxy failed to start.");
+ child.unref();
+ writePidFile(pidDir, child.pid);
+
+ const deadline = Date.now() + START_TIMEOUT_MS;
+ while (Date.now() < deadline) {
+ const state = readGooglechatWebhookProxyState(pidDir);
+ if (state.running && state.upstreamPort === upstreamPort) return state.port;
+ if (!processIsAlive(child.pid)) break;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+
+ try {
+ process.kill(child.pid, "SIGTERM");
+ } catch {
+ // Already exited.
+ }
+ removeProxyState(pidDir);
+ throw new Error("Google Chat webhook proxy did not become ready.");
+}
diff --git a/src/lib/tunnel/services.ts b/src/lib/tunnel/services.ts
index 8e80ae2ab9..cbeec0ff08 100644
--- a/src/lib/tunnel/services.ts
+++ b/src/lib/tunnel/services.ts
@@ -500,6 +500,28 @@ export function stopAll(opts: ServiceOptions = {}): void {
info("All services stopped.");
}
+/**
+ * Resolve the PID directory for host-side services without starting or stopping
+ * anything. Callers can derive an adjacent purpose-specific state directory
+ * while preserving the same validated sandbox-name and environment precedence
+ * used by `start`, `stop`, and `status`.
+ */
+export function resolveServicePidDir(opts: ServiceOptions = {}): string {
+ return resolvePidDir(opts);
+}
+
+/**
+ * Stop only the host-side cloudflared tunnel, leaving the in-sandbox gateway and
+ * Ollama untouched. `stopAll` is intentionally broader (it also stops the gateway
+ * and unloads Ollama); enrollment that auto-started a tunnel needs a tunnel-only
+ * stop to clean up without tearing down other services.
+ */
+export function stopCloudflared(opts: ServiceOptions = {}): void {
+ const pidDir = resolvePidDir(opts);
+ ensurePidDir(pidDir);
+ stopService(pidDir, "cloudflared");
+}
+
/**
* Sandbox name for tunnel-origin registration: same option/env precedence as
* the other service commands, gated on the safe-name rules, but without the
diff --git a/test/channels-add-bridge-lifecycle.test.ts b/test/channels-add-bridge-lifecycle.test.ts
new file mode 100644
index 0000000000..caf0ac198f
--- /dev/null
+++ b/test/channels-add-bridge-lifecycle.test.ts
@@ -0,0 +1,321 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+//
+// Bridge-provider lifecycle on the DIRECT `channels add` path (#6120): a
+// bridge-backed channel (googlechat) declares no manifest credentials, so the
+// add path must (1) create + refresh-configure the gateway bridge provider
+// itself, (2) fail loudly when the pasted secret is missing, and (3) tear the
+// just-created provider back down when gateway registration fails midway.
+
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from "vitest";
+import {
+ addSandboxChannel,
+ removeSandboxChannel,
+ startSandboxChannel,
+ stopSandboxChannel,
+} from "../src/lib/actions/sandbox/policy-channel";
+import { policyChannelDependencies } from "../src/lib/actions/sandbox/policy-channel-dependencies";
+import * as processRecovery from "../src/lib/actions/sandbox/process-recovery";
+import * as runtime from "../src/lib/adapters/openshell/runtime";
+import * as store from "../src/lib/credentials/store";
+import * as gatewayRuntime from "../src/lib/gateway-runtime-action";
+import { MESSAGING_BRIDGE_PENDING_VALUE } from "../src/lib/onboard/messaging-bridge-provider";
+import * as policies from "../src/lib/policy";
+import * as onboardSession from "../src/lib/state/onboard-session";
+import type { SandboxEntry } from "../src/lib/state/registry";
+import * as registry from "../src/lib/state/registry";
+
+class ExitError extends Error {
+ constructor(public readonly code: number | undefined) {
+ super(`process.exit(${code})`);
+ }
+}
+
+const SA_JSON = JSON.stringify({
+ client_email: "bot@p.iam.gserviceaccount.com",
+ private_key: "fake-test-private-key-material",
+});
+
+const GOOGLECHAT_ENV = {
+ GOOGLECHAT_SERVICE_ACCOUNT: SA_JSON,
+ GOOGLECHAT_AUDIENCE: "https://bot.example.com/googlechat",
+ GOOGLECHAT_APP_PRINCIPAL: "123456789012345678901",
+};
+
+// Why this mock exists: the real googlechat tunnel/audience gate needs a human
+// operator (Google Cloud Console steps), so on a non-interactive test run it
+// throws and the whole channel is skipped — the add path under test would
+// never execute.
+//
+// What it does: keep the module intact except the gate's registration, whose
+// handler is replaced with one that succeeds immediately — as if the operator
+// had already finished enrollment. Everything else in the add path runs real.
+type GateModule =
+ typeof import("../src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate");
+
+vi.mock(
+ "../src/lib/messaging/channels/googlechat/hooks/tunnel-audience-gate",
+ async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ createGooglechatTunnelAudienceGateHookRegistration: () => ({
+ id: actual.GOOGLECHAT_TUNNEL_AUDIENCE_GATE_HOOK_ID,
+ handler: async () => ({}),
+ }),
+ };
+ },
+);
+
+const originalProcessEnv = { ...process.env };
+
+let errorSpy: MockInstance;
+let logSpy: MockInstance;
+let exitSpy: MockInstance;
+let providerSpy: MockInstance;
+let runOpenshellSpy: MockInstance;
+let stopGooglechatWebhookTunnelSpy: MockInstance;
+let testHome: string;
+let registryEntry: SandboxEntry;
+let appliedPresets: string[];
+let session: onboardSession.Session;
+
+function printedText(): string {
+ return [...logSpy.mock.calls, ...errorSpy.mock.calls]
+ .map((call) => call.map(String).join(" "))
+ .join("\n");
+}
+
+function openshellCalls(): string[][] {
+ return runOpenshellSpy.mock.calls.map((call) => call[0] as string[]);
+}
+
+beforeEach(() => {
+ testHome = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-add-bridge-"));
+ process.env.HOME = testHome;
+ process.env.NEMOCLAW_NON_INTERACTIVE = "1";
+ Object.assign(process.env, GOOGLECHAT_ENV);
+
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
+ exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
+ throw new ExitError(code);
+ }) as never);
+
+ registryEntry = { name: "test-sb", agent: "openclaw", policies: [] } as SandboxEntry;
+ vi.spyOn(registry, "getSandbox").mockImplementation(() => registryEntry);
+ vi.spyOn(registry, "listSandboxes").mockImplementation(() => ({
+ sandboxes: [registryEntry],
+ defaultSandbox: "test-sb",
+ }));
+ vi.spyOn(registry, "updateSandbox").mockImplementation((_name, update) => {
+ registryEntry = { ...registryEntry, ...update } as SandboxEntry;
+ return true;
+ });
+ vi.spyOn(registry, "getDisabledChannels").mockImplementation(() => [
+ ...(registryEntry.messaging?.plan.disabledChannels ?? []),
+ ]);
+
+ appliedPresets = [];
+ vi.spyOn(policies, "loadPresetForSandbox").mockReturnValue(
+ "network_policies:\n stub:\n egress:\n - host: example.com\n",
+ );
+ vi.spyOn(policies, "applyPreset").mockImplementation((_sandboxName, preset) => {
+ appliedPresets = [...new Set([...appliedPresets, preset])];
+ return true;
+ });
+ vi.spyOn(policies, "removePreset").mockImplementation((_sandboxName, preset) => {
+ appliedPresets = appliedPresets.filter((name) => name !== preset);
+ return true;
+ });
+ vi.spyOn(policies, "getAppliedPresets").mockImplementation(() => [...appliedPresets]);
+
+ vi.spyOn(store, "getCredential").mockImplementation((key) => process.env[key] || null);
+ vi.spyOn(store, "saveCredential").mockImplementation(() => undefined);
+ vi.spyOn(store, "prompt").mockResolvedValue("y");
+
+ session = {
+ sandboxName: "test-sb",
+ policyPresets: [],
+ } as unknown as onboardSession.Session;
+ vi.spyOn(onboardSession, "loadSession").mockReturnValue(session);
+ vi.spyOn(onboardSession, "updateSession").mockImplementation((update) => {
+ session = update(session) ?? session;
+ return session;
+ });
+
+ // Keep the real provider orchestration on the success path so this test
+ // crosses the direct channel action, generic provider upsert, and OpenShell
+ // refresh boundary. Individual failure tests override the spy below.
+ providerSpy = vi.spyOn(policyChannelDependencies, "upsertMessagingProviders");
+ vi.spyOn(policyChannelDependencies, "rebuildSandbox").mockImplementation(async () => undefined);
+ stopGooglechatWebhookTunnelSpy = vi
+ .spyOn(policyChannelDependencies, "stopGooglechatWebhookTunnel")
+ .mockImplementation(() => undefined);
+
+ runOpenshellSpy = vi.spyOn(runtime, "runOpenshell").mockImplementation(() => ({
+ pid: 0,
+ output: [null, "", ""],
+ stdout: "",
+ stderr: "",
+ status: 0,
+ signal: null,
+ }));
+
+ const healthyGatewayState = {
+ state: "healthy_named",
+ status: "",
+ gatewayInfo: "",
+ activeGateway: "nemoclaw",
+ } as const;
+ vi.spyOn(gatewayRuntime, "recoverNamedGatewayRuntime").mockResolvedValue({
+ recovered: true,
+ before: healthyGatewayState,
+ after: healthyGatewayState,
+ attempted: false,
+ });
+
+ vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue({
+ status: 0,
+ stdout: "",
+ stderr: "",
+ });
+ vi.spyOn(processRecovery, "executeSandboxCommand").mockReturnValue(null);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ fs.rmSync(testHome, { recursive: true, force: true });
+ for (const key of Object.keys(process.env)) delete process.env[key];
+ Object.assign(process.env, originalProcessEnv);
+});
+
+describe("channels add owns the bridge-provider lifecycle (#6120)", () => {
+ it("creates the bridge while keeping service-account material outside argv and durable state", async () => {
+ await addSandboxChannel("test-sb", { channel: "googlechat" });
+
+ expect(providerSpy).toHaveBeenCalledWith(
+ [
+ {
+ name: "test-sb-googlechat-bridge",
+ envKey: "GOOGLE_CHAT_ACCESS_TOKEN",
+ token: MESSAGING_BRIDGE_PENDING_VALUE,
+ providerType: "google-chat-bridge",
+ },
+ ],
+ { bestEffort: true },
+ );
+ const refreshCall = runOpenshellSpy.mock.calls.find(
+ (call) =>
+ (call[0] as string[])[0] === "provider" &&
+ (call[0] as string[])[1] === "refresh" &&
+ (call[0] as string[])[2] === "configure",
+ );
+ expect(refreshCall).toBeDefined();
+ const refreshArgs = refreshCall?.[0] as string[];
+ expect(refreshArgs).toContain("--secret-material-env");
+ expect(refreshArgs).toContain("private_key=MESSAGING_BRIDGE_SECRET_0");
+ expect(refreshArgs.join(" ")).not.toContain("fake-test-private-key-material");
+ expect(refreshCall?.[1]).toMatchObject({
+ env: { MESSAGING_BRIDGE_SECRET_0: "fake-test-private-key-material" },
+ });
+ expect(JSON.stringify({ registryEntry, session })).not.toContain(
+ "fake-test-private-key-material",
+ );
+ expect(printedText()).toContain("Registered googlechat bridge");
+ });
+
+ it("fails loudly at add time when the bridge secret is not resolvable", async () => {
+ delete process.env.GOOGLECHAT_SERVICE_ACCOUNT;
+
+ await expect(addSandboxChannel("test-sb", { channel: "googlechat" })).rejects.toMatchObject({
+ code: 1,
+ });
+
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ expect(providerSpy).not.toHaveBeenCalled();
+ expect(printedText()).toContain("GOOGLECHAT_SERVICE_ACCOUNT");
+ });
+
+ it("tears the just-created bridge provider back down when gateway registration fails", async () => {
+ providerSpy.mockImplementation(() => {
+ throw new Error("simulated gateway failure");
+ });
+
+ await expect(addSandboxChannel("test-sb", { channel: "googlechat" })).rejects.toMatchObject({
+ code: 1,
+ });
+
+ expect(printedText()).toContain("Failed to register 'googlechat' providers");
+ expect(openshellCalls()).toEqual(
+ expect.arrayContaining([
+ ["sandbox", "provider", "detach", "test-sb", "test-sb-googlechat-bridge"],
+ ["provider", "delete", "test-sb-googlechat-bridge"],
+ ]),
+ );
+ });
+
+ it("removes the bridge provider, policy, and durable plan through the channel action", async () => {
+ await addSandboxChannel("test-sb", { channel: "googlechat" });
+ expect(registry.getConfiguredMessagingChannelsFromEntry(registryEntry)).toContain("googlechat");
+ expect(appliedPresets).toContain("googlechat");
+
+ runOpenshellSpy.mockClear();
+ await removeSandboxChannel("test-sb", { channel: "googlechat" });
+
+ expect(openshellCalls()).toEqual(
+ expect.arrayContaining([
+ ["sandbox", "provider", "detach", "test-sb", "test-sb-googlechat-bridge"],
+ ["provider", "delete", "test-sb-googlechat-bridge"],
+ ]),
+ );
+ expect(registry.getConfiguredMessagingChannelsFromEntry(registryEntry)).not.toContain(
+ "googlechat",
+ );
+ expect(appliedPresets).not.toContain("googlechat");
+ expect(session.policyPresets).not.toContain("googlechat");
+ expect(stopGooglechatWebhookTunnelSpy).toHaveBeenCalledWith("test-sb");
+ });
+
+ it("preserves the bridge and webhook endpoint while stop/start restores the enabled plan", async () => {
+ await addSandboxChannel("test-sb", { channel: "googlechat" });
+ providerSpy.mockClear();
+ runOpenshellSpy.mockClear();
+ stopGooglechatWebhookTunnelSpy.mockClear();
+ vi.mocked(policies.applyPreset).mockClear();
+
+ await stopSandboxChannel("test-sb", { channel: "googlechat" });
+
+ const stoppedPlan = registryEntry.messaging?.plan;
+ expect(stoppedPlan?.workflow).toBe("stop-channel");
+ expect(stoppedPlan?.disabledChannels).toEqual(["googlechat"]);
+ expect(stoppedPlan?.channels).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ channelId: "googlechat", active: false, disabled: true }),
+ ]),
+ );
+ expect(providerSpy).not.toHaveBeenCalled();
+ expect(openshellCalls()).toEqual([]);
+ expect(stopGooglechatWebhookTunnelSpy).not.toHaveBeenCalled();
+
+ await startSandboxChannel("test-sb", { channel: "googlechat" });
+
+ const startedPlan = registryEntry.messaging?.plan;
+ expect(startedPlan?.workflow).toBe("start-channel");
+ expect(startedPlan?.disabledChannels).toEqual([]);
+ expect(startedPlan?.channels).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ channelId: "googlechat", active: true, disabled: false }),
+ ]),
+ );
+ expect(policies.applyPreset).toHaveBeenCalledWith("test-sb", "googlechat");
+ expect(appliedPresets).toContain("googlechat");
+ expect(session.policyPresets).toContain("googlechat");
+ expect(providerSpy).not.toHaveBeenCalled();
+ expect(openshellCalls()).toEqual([]);
+ expect(stopGooglechatWebhookTunnelSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts
index ea5f946dba..f93048bea6 100644
--- a/test/channels-add-preset.test.ts
+++ b/test/channels-add-preset.test.ts
@@ -332,7 +332,15 @@ describe("channels add applies a matching policy preset (#3437)", () => {
isInteractive: false,
configuredChannels: ["slack"],
disabledChannels: [],
- supportedChannelIds: ["telegram", "discord", "wechat", "slack", "whatsapp", "teams"],
+ supportedChannelIds: [
+ "telegram",
+ "discord",
+ "wechat",
+ "slack",
+ "whatsapp",
+ "teams",
+ "googlechat",
+ ],
credentialAvailability: expect.any(Object),
});
});
diff --git a/test/destroy-cleanup-sandbox-services.test.ts b/test/destroy-cleanup-sandbox-services.test.ts
index 7e3df5163d..2df79de398 100644
--- a/test/destroy-cleanup-sandbox-services.test.ts
+++ b/test/destroy-cleanup-sandbox-services.test.ts
@@ -20,7 +20,12 @@ function buildDeps(sandbox: SandboxLike): {
deps: Required<
Pick<
CleanupSandboxServicesDeps,
- "getSandbox" | "stopAll" | "unloadOllamaModels" | "runOpenshell" | "rmSync"
+ | "getSandbox"
+ | "stopAll"
+ | "unloadOllamaModels"
+ | "runOpenshell"
+ | "rmSync"
+ | "stopGooglechatWebhookTunnel"
>
>;
stopAllCalls: Array<{ sandboxName: string }>;
@@ -43,6 +48,7 @@ function buildDeps(sandbox: SandboxLike): {
}),
runOpenshell: vi.fn(() => ({ status: 0 })),
rmSync: vi.fn(),
+ stopGooglechatWebhookTunnel: vi.fn(() => "/tmp/nemoclaw-services-regression-2717-googlechat"),
},
};
}
@@ -89,6 +95,11 @@ describe("cleanupSandboxServices Ollama unload (#2717)", () => {
path.join("/tmp", "nemoclaw-services-regression-2717"),
{ recursive: true, force: true },
);
+ expect(harness.deps.stopGooglechatWebhookTunnel).toHaveBeenCalledWith("regression-2717");
+ expect(harness.deps.rmSync).toHaveBeenCalledWith(
+ path.join("/tmp", "nemoclaw-services-regression-2717-googlechat"),
+ { recursive: true, force: true },
+ );
const providerDeleteCalls = vi
.mocked(harness.deps.runOpenshell)
@@ -111,5 +122,6 @@ describe("cleanupSandboxServices Ollama unload (#2717)", () => {
expect(harness.deps.unloadOllamaModels).not.toHaveBeenCalled();
expect(harness.deps.rmSync).not.toHaveBeenCalled();
expect(harness.deps.runOpenshell).not.toHaveBeenCalled();
+ expect(harness.deps.stopGooglechatWebhookTunnel).not.toHaveBeenCalled();
});
});
diff --git a/test/e2e/live/channels-stop-start-helpers.ts b/test/e2e/live/channels-stop-start-helpers.ts
index 5fe1a4d36a..a6b036d2d5 100644
--- a/test/e2e/live/channels-stop-start-helpers.ts
+++ b/test/e2e/live/channels-stop-start-helpers.ts
@@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { expect } from "../fixtures/e2e-test.ts";
+import { startChannelsStopStartProgress } from "./channels-stop-start-progress.ts";
import { assertChannelsStopStartSandboxName } from "./channels-stop-start-safety.ts";
import {
type AgentKind,
@@ -439,6 +440,9 @@ export async function runChannelsStopStartTarget({
channels: CHANNELS,
});
+ const progress = startChannelsStopStartProgress(AGENT);
+ cleanup.trackDisposable("stop channels stop/start progress", progress.stop);
+
cleanup.trackGateway(host, "nemoclaw", {
artifactName: `cleanup-openshell-gateway-destroy-${AGENT}`,
env,
diff --git a/test/e2e/live/channels-stop-start-progress.ts b/test/e2e/live/channels-stop-start-progress.ts
new file mode 100644
index 0000000000..0cb90dec7f
--- /dev/null
+++ b/test/e2e/live/channels-stop-start-progress.ts
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+interface TimerHandle {
+ unref?: () => void;
+}
+
+export interface ChannelsStopStartProgressOptions {
+ heartbeatIntervalMs?: number;
+ setTimer?: (callback: () => void, intervalMs: number) => TimerHandle;
+ clearTimer?: (timer: TimerHandle) => void;
+ logLine?: (line: string) => void;
+}
+
+export interface ChannelsStopStartProgress {
+ stop: () => void;
+}
+
+const DEFAULT_HEARTBEAT_INTERVAL_MS = 60_000;
+
+/**
+ * Keep the long channel lifecycle target visible without forwarding captured
+ * command output, which may contain credentials.
+ */
+export function startChannelsStopStartProgress(
+ agent: "openclaw" | "hermes",
+ options: ChannelsStopStartProgressOptions = {},
+): ChannelsStopStartProgress {
+ const setTimer =
+ options.setTimer ?? ((callback, intervalMs) => setInterval(callback, intervalMs));
+ const clearTimer = options.clearTimer ?? ((timer) => clearInterval(timer as NodeJS.Timeout));
+ const logLine = options.logLine ?? ((line) => process.stdout.write(`${line}\n`));
+ let stopped = false;
+
+ const timer = setTimer(() => {
+ try {
+ logLine(`[channels-stop-start] live test still running for ${agent}`);
+ } catch {
+ // Diagnostics must not change the live test result.
+ }
+ }, options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS);
+ timer.unref?.();
+
+ return {
+ stop() {
+ if (stopped) return;
+ stopped = true;
+ clearTimer(timer);
+ },
+ };
+}
diff --git a/test/e2e/support/channels-stop-start-progress.test.ts b/test/e2e/support/channels-stop-start-progress.test.ts
new file mode 100644
index 0000000000..fcd2168c91
--- /dev/null
+++ b/test/e2e/support/channels-stop-start-progress.test.ts
@@ -0,0 +1,60 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it, vi } from "vitest";
+import {
+ type ChannelsStopStartProgressOptions,
+ startChannelsStopStartProgress,
+} from "../live/channels-stop-start-progress.ts";
+
+function progressHarness() {
+ const state = {
+ clearCalls: 0,
+ lines: [] as string[],
+ timerCallback: null as (() => void) | null,
+ unrefCalls: 0,
+ };
+ const options: ChannelsStopStartProgressOptions = {
+ heartbeatIntervalMs: 60_000,
+ setTimer: (callback) => {
+ state.timerCallback = callback;
+ return {
+ unref() {
+ state.unrefCalls += 1;
+ },
+ };
+ },
+ clearTimer: () => {
+ state.clearCalls += 1;
+ },
+ logLine: (line) => state.lines.push(line),
+ };
+ return { options, state };
+}
+
+describe("channels stop/start live progress", () => {
+ it("emits secret-free liveness and stops idempotently", () => {
+ const { options, state } = progressHarness();
+ const progress = startChannelsStopStartProgress("hermes", options);
+
+ state.timerCallback?.();
+ progress.stop();
+ progress.stop();
+
+ expect(state.unrefCalls).toBe(1);
+ expect(state.clearCalls).toBe(1);
+ expect(state.lines).toEqual(["[channels-stop-start] live test still running for hermes"]);
+ });
+
+ it("keeps diagnostics best-effort when output fails", () => {
+ const { options, state } = progressHarness();
+ options.logLine = vi.fn(() => {
+ throw new Error("closed output");
+ });
+ const progress = startChannelsStopStartProgress("openclaw", options);
+
+ expect(() => state.timerCallback?.()).not.toThrow();
+ progress.stop();
+ expect(state.clearCalls).toBe(1);
+ });
+});
diff --git a/test/messaging-build-applier-integrity.test.ts b/test/messaging-build-applier-integrity.test.ts
index 3aab16739b..cda140a9d8 100644
--- a/test/messaging-build-applier-integrity.test.ts
+++ b/test/messaging-build-applier-integrity.test.ts
@@ -165,6 +165,8 @@ describe("messaging-build-applier.mts: plugin archive integrity", () => {
).toEqual({
"@openclaw/discord@2026.6.10":
"https://registry.npmjs.org/@openclaw/discord/-/discord-2026.6.10.tgz",
+ "@openclaw/googlechat@2026.6.10":
+ "https://registry.npmjs.org/@openclaw/googlechat/-/googlechat-2026.6.10.tgz",
"@openclaw/msteams@2026.6.10":
"https://registry.npmjs.org/@openclaw/msteams/-/msteams-2026.6.10.tgz",
"@openclaw/slack@2026.6.10": OPENCLAW_SLACK_2026_6_10_TARBALL,
diff --git a/test/onboard-readiness.test.ts b/test/onboard-readiness.test.ts
index 1d9e4cb1f2..c57b37b6f0 100644
--- a/test/onboard-readiness.test.ts
+++ b/test/onboard-readiness.test.ts
@@ -2,7 +2,12 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from "vitest";
-import { applyPreset, buildPolicyGetCommand, buildPolicySetCommand } from "../src/lib/policy";
+import {
+ applyPreset,
+ buildPolicyGetCommand,
+ buildPolicyGetFullCommand,
+ buildPolicySetCommand,
+} from "../src/lib/policy";
type OnboardReadinessInternals = {
hasStaleGateway: (output: string | null | undefined) => boolean;
@@ -128,6 +133,12 @@ describe("WSL sandbox name handling", () => {
expect(cmd).toContain("my-assistant");
});
+ it("buildPolicyGetFullCommand preserves hyphenated sandbox name", () => {
+ const cmd = buildPolicyGetFullCommand("my-assistant");
+ expect(cmd).toContain("my-assistant");
+ expect(cmd).toContain("--full");
+ });
+
it("buildPolicySetCommand preserves multi-hyphen names", () => {
const cmd = buildPolicySetCommand("/tmp/p.yaml", "my-dev-assistant-v2");
expect(cmd).toContain("my-dev-assistant-v2");