rebuild --yes",
+ ].join("\n"),
emptyValueMessage: "Workspace accounts do not need it; personal accounts must set it later",
},
},
@@ -106,11 +104,52 @@ export const googlechatManifest = {
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).",
+ label: "Google Chat DM allowlist (comma-separated)",
+ help: [
+ "Optional: restrict who can DM the bot.",
+ " OpenClaw: users/NNN (emails ignored)",
+ " Hermes: email (users/NNN ignored)",
+ " Blank: pairing mode (recommended) — OpenClaw's pairing reply shows your users/NNN",
+ " Filling this switches DM policy to allowlist — a wrong-form entry is dropped silently, with no pairing code.",
+ ].join("\n"),
emptyValueMessage: "bot will require manual pairing",
},
},
+ // ── Hermes-only Pub/Sub pull config ──
+ // Hermes supports a webhook too, but NemoClaw pulls instead, so it needs the
+ // project and subscription. OpenClaw ignores both. Rendered only into
+ // ~/.hermes/.env.
+ {
+ id: "projectId",
+ kind: "config",
+ required: false,
+ envKey: "GOOGLE_CHAT_PROJECT_ID",
+ statePath: "googlechatConfig.projectId",
+ prompt: {
+ label: "Google Chat GCP project ID (Hermes Pub/Sub pull)",
+ help: "The Google Cloud project that owns the Pub/Sub subscription Hermes pulls Chat events from. OpenClaw ignores this.",
+ emptyValueMessage: "required for the Hermes Google Chat channel",
+ },
+ },
+ {
+ id: "subscriptionName",
+ kind: "config",
+ required: false,
+ envKey: "GOOGLE_CHAT_SUBSCRIPTION_NAME",
+ statePath: "googlechatConfig.subscriptionName",
+ prompt: {
+ label: "Google Chat Pub/Sub subscription (projects//subscriptions/)",
+ help: [
+ "The pull subscription bound to the Chat events topic. Hermes pulls from it over the Pub/Sub REST API; the gateway-minted token is scoped to both chat.bot and pubsub.",
+ " Its topic must grant roles/pubsub.publisher to the app's push account:",
+ " Interactive features service-@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
+ " Classic bot chat-api-push@system.gserviceaccount.com",
+ " Shown at Chat API → Configuration → Connection settings",
+ " Missing it channel connects, no event arrives, Chat says the bot is not responding",
+ ].join("\n"),
+ emptyValueMessage: "required for the Hermes Google Chat channel",
+ },
+ },
],
// Outbound auth is gateway-minted: the OpenShell `google-service-account-jwt`
// refresh provider mints the Google Chat bot token from the pasted service
@@ -129,7 +168,17 @@ export const googlechatManifest = {
// clears only per-channel sandbox tokens and is intentionally a no-op for a bridge
// channel, so an empty `credentials` does not leave the service account behind.
credentials: [],
- policyPresets: [{ name: "googlechat", policyKeys: ["googlechat"] }],
+ policyPresets: [
+ {
+ name: "googlechat",
+ policyKeys: ["googlechat"],
+ // Pub/Sub REST pull + Chat REST reply is a different egress shape from
+ // OpenClaw's inbound webhook, so it resolves its own policy key.
+ agentPolicyKeys: {
+ hermes: ["googlechat_hermes"],
+ },
+ },
+ ],
render: [
{
id: "googlechat-openclaw-channel",
@@ -201,6 +250,32 @@ export const googlechatManifest = {
},
},
},
+ // ── Hermes render ──
+ // Non-secret pull config and the allowlist only: no SA JSON or token reaches
+ // the sandbox, which sends the placeholder the L7 proxy swaps.
+ {
+ id: "googlechat-hermes-env",
+ kind: "env-lines",
+ agent: "hermes",
+ target: "~/.hermes/.env",
+ lines: [
+ "GOOGLE_CHAT_PROJECT_ID={{googlechatConfig.projectId}}",
+ "GOOGLE_CHAT_SUBSCRIPTION_NAME={{googlechatConfig.subscriptionName}}",
+ "GOOGLE_CHAT_ALLOWED_USERS={{allowedIds.googlechat.csv}}",
+ ],
+ },
+ {
+ id: "googlechat-hermes-platform",
+ kind: "json-fragment",
+ agent: "hermes",
+ target: "~/.hermes/config.yaml",
+ fragment: {
+ path: "platforms.google_chat",
+ value: {
+ enabled: true,
+ },
+ },
+ },
],
runtime: {
openclaw: {
@@ -275,12 +350,39 @@ export const googlechatManifest = {
},
required: true,
},
+ // The base image ships aiohttp but not the google-* SDKs, which the inherited
+ // connect() and reply path both need.
+ {
+ id: "hermesGooglePubsubPackage",
+ agent: "hermes",
+ manager: "hermes-uv-pip",
+ spec: "google-cloud-pubsub==2.39.0",
+ required: true,
+ },
+ {
+ id: "hermesGoogleApiClientPackage",
+ agent: "hermes",
+ manager: "hermes-uv-pip",
+ spec: "google-api-python-client==2.194.0",
+ required: true,
+ },
+ {
+ id: "hermesGoogleAuthPackage",
+ agent: "hermes",
+ manager: "hermes-uv-pip",
+ spec: "google-auth==2.55.1",
+ required: true,
+ },
],
hooks: [
{
+ // OpenClaw-only: gates the inbound webhook audience. Hermes pull mode
+ // serves no webhook, and running this gate would skip the channel and drop
+ // its policy preset.
id: "googlechat-tunnel-audience-gate",
phase: "enroll",
handler: "googlechat.tunnelAudienceGate",
+ agents: ["openclaw"],
inputs: ["audienceType", "audience"],
outputs: [
{
@@ -307,13 +409,40 @@ export const googlechatManifest = {
id: "googlechat-config-prompt",
phase: "enroll",
handler: "common.configPrompt",
+ outputs: [
+ {
+ id: "allowFrom",
+ kind: "config",
+ },
+ ],
+ },
+ {
+ // OpenClaw-only: appPrincipal is the add-on's OAuth principal for inbound
+ // webhook verification — meaningless to Hermes pull mode.
+ id: "googlechat-openclaw-config-prompt",
+ phase: "enroll",
+ handler: "common.configPrompt",
+ agents: ["openclaw"],
outputs: [
{
id: "appPrincipal",
kind: "config",
},
+ ],
+ },
+ {
+ // Hermes-only: collect the Pub/Sub project + subscription for pull mode.
+ id: "googlechat-hermes-config-prompt",
+ phase: "enroll",
+ handler: "common.configPrompt",
+ agents: ["hermes"],
+ outputs: [
+ {
+ id: "projectId",
+ kind: "config",
+ },
{
- id: "allowFrom",
+ id: "subscriptionName",
kind: "config",
},
],
diff --git a/src/lib/messaging/channels/googlechat/policy.test.ts b/src/lib/messaging/channels/googlechat/policy.test.ts
new file mode 100644
index 00000000000..9afdec1459d
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/policy.test.ts
@@ -0,0 +1,57 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { describe, expect, it } from "vitest";
+import YAML from "yaml";
+
+import { loadMessagingChannelPolicyPreset } from "../policy";
+
+type PolicyRule = { readonly allow?: { readonly method?: string; readonly path?: string } };
+type PolicyEndpoint = { readonly host?: string; readonly rules?: readonly PolicyRule[] };
+
+const PUBSUB_PULL = "/v1/projects/*/subscriptions/*:pull";
+const PUBSUB_ACKNOWLEDGE = "/v1/projects/*/subscriptions/*:acknowledge";
+
+function endpointsFor(agent: string): readonly PolicyEndpoint[] {
+ const preset = loadMessagingChannelPolicyPreset("googlechat", { agent });
+ expect(preset, `no googlechat policy preset for ${agent}`).toBeTruthy();
+ const parsed = YAML.parse(preset ?? "") as {
+ network_policies?: Record;
+ };
+ return Object.values(parsed.network_policies ?? {}).flatMap((policy) => policy.endpoints ?? []);
+}
+
+function rulesForHost(agent: string, host: string): readonly PolicyRule[] {
+ return endpointsFor(agent).find((endpoint) => endpoint.host === host)?.rules ?? [];
+}
+
+describe("Google Chat Hermes egress policy", () => {
+ // The adapter test proves the override only issues :pull and :acknowledge. This
+ // pins the other half: the preset must not hand the sandbox anything wider,
+ // because a `/v1/**` allow would also cover publish and subscription admin.
+ it("allows exactly the two Pub/Sub operations the REST pull issues", () => {
+ expect(rulesForHost("hermes", "pubsub.googleapis.com")).toEqual([
+ { allow: { method: "POST", path: PUBSUB_PULL } },
+ { allow: { method: "POST", path: PUBSUB_ACKNOWLEDGE } },
+ ]);
+ });
+
+ it("keeps Chat writes inside the spaces tree", () => {
+ expect(rulesForHost("hermes", "chat.googleapis.com")).toEqual([
+ { allow: { method: "GET", path: "/v1/**" } },
+ { allow: { method: "POST", path: "/v1/spaces/**" } },
+ { allow: { method: "PATCH", path: "/v1/spaces/**" } },
+ { allow: { method: "DELETE", path: "/v1/spaces/**" } },
+ ]);
+ });
+
+ it("reaches no host beyond Pub/Sub and Chat", () => {
+ expect(new Set(endpointsFor("hermes").map((endpoint) => endpoint.host))).toEqual(
+ new Set(["pubsub.googleapis.com", "chat.googleapis.com"]),
+ );
+ });
+
+ it("grants OpenClaw no Pub/Sub egress, since it runs on an inbound webhook", () => {
+ expect(rulesForHost("openclaw", "pubsub.googleapis.com")).toEqual([]);
+ });
+});
diff --git a/src/lib/messaging/channels/googlechat/policy/hermes.yaml b/src/lib/messaging/channels/googlechat/policy/hermes.yaml
new file mode 100644
index 00000000000..85d5886abb9
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/policy/hermes.yaml
@@ -0,0 +1,41 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+preset:
+ name: googlechat
+ description: "Hermes Google Chat: Pub/Sub REST pull for inbound events + Chat REST reply; the bot token is minted gateway-side (chat.bot + pubsub), never in the sandbox"
+
+network_policies:
+ googlechat_hermes:
+ name: googlechat_hermes
+ endpoints:
+ # Inbound: REST :pull / :acknowledge, not gRPC StreamingPull — REST keeps
+ # the egress inspectable so the proxy can inject the minted token.
+ - host: pubsub.googleapis.com
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ rules:
+ # The L7 matcher is `glob.match(pattern, ["/"], path)`, so `*` spans the
+ # `:verb` suffix. Allow only the two operations the adapter issues: a
+ # broader `/v1/**` would also permit publish and subscription admin.
+ - allow: { method: POST, path: "/v1/projects/*/subscriptions/*:pull" }
+ - allow: { method: POST, path: "/v1/projects/*/subscriptions/*:acknowledge" }
+ # Outbound reply: same rule shape as the OpenClaw preset — writes scoped to
+ # the Chat `spaces` tree, reads across the v1 tree.
+ - host: chat.googleapis.com
+ port: 443
+ protocol: rest
+ enforcement: enforce
+ rules:
+ - allow: { method: GET, path: "/v1/**" }
+ - allow: { method: POST, path: "/v1/spaces/**" }
+ - allow: { method: PATCH, path: "/v1/spaces/**" }
+ - allow: { method: DELETE, path: "/v1/spaces/**" }
+ # OpenShell matches the resolved realpath EXACTLY, and Hermes' venv python
+ # resolves to /usr/bin/python3.13, so the concrete path must be listed.
+ # Revisit when the base image bumps Python.
+ binaries:
+ - { path: /opt/hermes/.venv/bin/python }
+ - { path: /usr/bin/python3 }
+ - { path: /usr/bin/python3.13 }
diff --git a/src/lib/messaging/channels/googlechat/provider-profile/hermes.yaml b/src/lib/messaging/channels/googlechat/provider-profile/hermes.yaml
new file mode 100644
index 00000000000..9f6af94e8de
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/provider-profile/hermes.yaml
@@ -0,0 +1,54 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Google Chat bridge for Hermes: keyless reply + Pub/Sub pull. The gateway mints
+# one chat.bot + pubsub token and injects it on both hosts; the private key stays
+# gateway-side, delivered by messaging-bridge-provider.ts through
+# `--secret-material-env` so it never reaches argv or the sandbox.
+id: google-chat-hermes-bridge
+display_name: Google Chat Bridge (Hermes)
+description: Gateway-minted Google Chat bot token (chat.bot + pubsub) for Hermes Pub/Sub pull + Chat reply
+category: agent
+credentials:
+ - name: access_token
+ description: Google Chat bot OAuth access token (gateway-minted via service-account JWT; chat.bot + pubsub)
+ 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
+ - https://www.googleapis.com/auth/pubsub
+ 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: pubsub.googleapis.com
+ port: 443
+ protocol: rest
+ access: read-write
+ enforcement: enforce
+ - host: chat.googleapis.com
+ port: 443
+ protocol: rest
+ access: read-write
+ enforcement: enforce
+# OpenShell resolves the process via /proc/pid/exe and matches binaries EXACTLY:
+# a `/usr/bin/python3*` glob misses `python3.13` and is 403'd at CONNECT, so list
+# the concrete path Hermes' venv resolves to. Revisit on a base-image Python bump.
+binaries:
+ - /opt/hermes/.venv/bin/python
+ - /usr/bin/python3
+ - /usr/bin/python3.13
+inference_capable: false
diff --git a/src/lib/messaging/channels/googlechat/runtime-contract.test.ts b/src/lib/messaging/channels/googlechat/runtime-contract.test.ts
index 1c4cd924dab..e443a7ec3c2 100644
--- a/src/lib/messaging/channels/googlechat/runtime-contract.test.ts
+++ b/src/lib/messaging/channels/googlechat/runtime-contract.test.ts
@@ -17,8 +17,12 @@ const googlechatPolicy = readFileSync(new URL("./policy/openclaw.yaml", import.m
// wiring a refactor could otherwise weaken silently in the unit lane.
function renderFragmentValue(path: string): Record {
- const entry = googlechatManifest.render.find((fragment) => fragment.fragment.path === path);
- return (entry?.fragment.value ?? {}) as Record;
+ const entry = googlechatManifest.render.find(
+ (render) => "fragment" in render && render.fragment.path === path,
+ );
+ return entry && "fragment" in entry
+ ? ((entry.fragment.value ?? {}) as Record)
+ : {};
}
describe("googlechat runtime security contract", () => {
diff --git a/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py b/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py
new file mode 100644
index 00000000000..d478dcf49eb
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py
@@ -0,0 +1,339 @@
+# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+"""Google Chat connect-time shim for Hermes inside a NemoClaw sandbox.
+
+Channel-owned runtime asset (`src/lib/messaging/AGENTS.md`), beside OpenClaw's
+`googlechat-*.ts` preloads. The sandbox forces two deltas on the bundled
+adapter, and everything else runs unchanged through a subclass:
+
+* Inbound — the OpenShell L7 protocol set has no gRPC, so StreamingPull cannot
+ be inspected; pull the same subscription over the Pub/Sub REST API.
+* Outbound — the service-account key stays gateway-side, so requests carry the
+ `openshell:resolve:env:GOOGLE_CHAT_ACCESS_TOKEN` placeholder over aiohttp.
+
+It attaches through published seams: `platform_registry.get()` forces the
+deferred loader, `dataclasses.replace` keeps every `PlatformEntry` field, and
+`register()` documents last-writer-wins. `_validate_config`,
+`_load_sa_credentials` and `_new_authed_http` are Hermes internals only because
+`PlatformEntry` carries no credential or transport field;
+`image-build-probes.py googlechat-override-seams` pins them so drift fails the
+image build instead of silently restoring the stock gRPC adapter.
+"""
+
+import asyncio
+import dataclasses
+import importlib.util
+import logging
+
+# Not a credential: the L7 proxy swaps this resolver placeholder at egress.
+_GC_REST_PLACEHOLDER_TOKEN = "openshell:resolve:env:GOOGLE_CHAT_ACCESS_TOKEN" # noqa: S105
+_GC_PULL_TIMEOUT = 95.0 # aiohttp cap > the Pub/Sub server long-poll hold (~90s)
+_GC_LOG = logging.getLogger("gateway.platforms.google_chat")
+
+
+class _RestPubsubMessage:
+ """REST ``receivedMessage`` shaped as what ``_on_pubsub_message`` touches."""
+
+ def __init__(self, pmsg, ack_sink, ack_id):
+ import base64
+
+ self._ack_sink = ack_sink
+ self._ack_id = ack_id
+ raw = pmsg.get("data") or ""
+ self.data = base64.b64decode(raw) if raw else b""
+ self.attributes = pmsg.get("attributes") or {}
+
+ def ack(self):
+ if self._ack_id:
+ self._ack_sink.append(self._ack_id)
+
+ def nack(self):
+ # Omit the ackId -> Pub/Sub redelivers, matching message.nack().
+ pass
+
+
+def _gc_placeholder_credentials():
+ """Credentials carrying the placeholder the L7 proxy swaps; nothing is signed here."""
+ from google.auth import credentials as ga_credentials
+
+ class _PlaceholderCredentials(ga_credentials.Credentials):
+ def __init__(self):
+ super().__init__()
+ self.token = _GC_REST_PLACEHOLDER_TOKEN
+
+ def refresh(self, request): # signed gateway-side; nothing to do here
+ self.token = _GC_REST_PLACEHOLDER_TOKEN
+
+ return _PlaceholderCredentials()
+
+
+def _gc_gateway_proxy_url():
+ """Return the egress proxy URL, or ``""`` for direct egress.
+
+ Read from the gateway's ``/proc//environ``: it clears the proxy vars
+ during a turn, and replies run in a worker thread that never carried them.
+ """
+ import glob
+
+ for cmd_path in glob.glob("/proc/[0-9]*/cmdline"):
+ try:
+ with open(cmd_path, "rb") as cmdline_handle:
+ cmdline = cmdline_handle.read()
+ except OSError:
+ continue
+ if b"hermes.real" not in cmdline or b"gateway" not in cmdline or b"dashboard" in cmdline:
+ continue
+ try:
+ with open(cmd_path.rsplit("/", 1)[0] + "/environ", "rb") as handle:
+ env = {}
+ for pair in handle.read().split(b"\0"):
+ key, sep, value = pair.partition(b"=")
+ if sep:
+ env[key.decode("utf-8", "replace")] = value.decode("utf-8", "replace")
+ except OSError:
+ continue
+ url = (
+ env.get("https_proxy")
+ or env.get("HTTPS_PROXY")
+ or env.get("http_proxy")
+ or env.get("HTTP_PROXY")
+ or ""
+ )
+ if url:
+ return url
+ return ""
+
+
+class _GcAiohttpTransport:
+ """``httplib2.Http``-shaped transport (``.request()`` only) built on aiohttp.
+
+ googleapiclient only calls ``.request()`` and reads ``resp.status`` plus
+ content, so an ``httplib2.Response`` satisfies it. httplib2 itself cannot be
+ used: without PySocks it connects direct, which the proxy-only netns cannot
+ resolve, and with PySocks ``PROXY_TYPE_HTTP`` refuses the CONNECT tunnel.
+ """
+
+ def request(self, uri, method="GET", body=None, headers=None, **kwargs):
+ import aiohttp
+
+ import plugins.platforms.google_chat.adapter as _gc
+
+ proxy = _gc_gateway_proxy_url() or None
+ data = body.encode("utf-8") if isinstance(body, str) else body
+
+ async def _run():
+ # Mirror the inbound :pull: ClientSession(trust_env=True) with no ssl
+ # override, whose default trust already accepts the proxy's MITM chain
+ # (a hand-built CA context was rejected). The proxy is passed explicitly
+ # because os.environ may be cleared by reply time.
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.request(
+ method,
+ uri,
+ data=data,
+ headers=dict(headers or {}),
+ proxy=proxy,
+ timeout=aiohttp.ClientTimeout(total=60),
+ ) as resp:
+ content = await resp.read()
+ info = {"status": resp.status}
+ for key, value in resp.headers.items():
+ info[key.lower()] = value
+ return _gc.httplib2.Response(info), content
+
+ # Chat calls run under ``asyncio.to_thread``, so a fresh loop is safe here.
+ return asyncio.run(_run())
+
+
+def _gc_spec_available(module_name: str) -> bool:
+ try:
+ return importlib.util.find_spec(module_name) is not None
+ except (ImportError, ValueError):
+ return False
+
+
+def _nemoclaw_gc_check() -> bool:
+ """Passive probe: aiohttp for the pull, google SDKs for the inherited paths."""
+ return all(
+ _gc_spec_available(module_name)
+ for module_name in ("aiohttp", "google.auth", "google.cloud.pubsub_v1")
+ )
+
+
+def _sandbox_adapter_class():
+ """Build the subclass lazily: the bundled module only imports inside the sandbox."""
+ import plugins.platforms.google_chat.adapter as _gc
+
+ class SandboxGoogleChatAdapter(_gc.GoogleChatAdapter):
+ """Bundled adapter with the sandbox transport and credential delta."""
+
+ _sandbox_subscription = None
+
+ def _validate_config(self):
+ """Report no subscription so the bundled ``connect()`` skips its gRPC
+ precheck and supervisor; upstream validation still runs, and the real
+ subscription is kept for ``_rest_pull``.
+ """
+ project_id, subscription_path = super()._validate_config()
+ self._sandbox_subscription = subscription_path
+ return project_id, None
+
+ def _load_sa_credentials(self):
+ """Return the placeholder the L7 proxy swaps; nothing is signed here."""
+ return _gc_placeholder_credentials()
+
+ def _new_authed_http(self):
+ """Route every Chat REST call (reply, patch, typing card, bot-id lookup)
+ through aiohttp instead of httplib2, still on the placeholder token.
+ """
+ import plugins.platforms.google_chat.adapter as _gc
+
+ return _gc.AuthorizedHttp(self._credentials, http=_GcAiohttpTransport())
+
+ async def connect(self, *, is_reconnect: bool = False) -> bool:
+ """Run the bundled connect(), then start the REST pull it skipped."""
+ connected = await super().connect(is_reconnect=is_reconnect)
+ # Hermes v2026.7.20 builds a fresh adapter for every reconnect, so this
+ # never runs twice on one instance; a release that reuses the instance
+ # would need the running pull replaced here.
+ if connected and self._sandbox_subscription:
+ self._supervisor_task = asyncio.create_task(self._rest_pull())
+ _GC_LOG.info(
+ "[GoogleChat][NemoClaw] keyless REST pull active (no gRPC subscriber)"
+ )
+ return connected
+
+ async def _rest_pull(self):
+ """Pull the subscription over the Pub/Sub REST unary API, in place of the
+ bundled gRPC supervisor. Messages reach the unchanged
+ ``_on_pubsub_message`` in a worker thread, keeping its contract.
+ """
+ import aiohttp
+
+ sub = self._sandbox_subscription
+ pull_url = f"https://pubsub.googleapis.com/v1/{sub}:pull"
+ ack_url = f"https://pubsub.googleapis.com/v1/{sub}:acknowledge"
+ headers = {
+ "Authorization": f"Bearer {_GC_REST_PLACEHOLDER_TOKEN}",
+ "Content-Type": "application/json",
+ }
+ _GC_LOG.info(
+ "[GoogleChat][NemoClaw] keyless Pub/Sub REST :pull transport active (sub=%s)",
+ sub,
+ )
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ while not self._shutting_down:
+ try:
+ async with session.post(
+ pull_url,
+ json={"maxMessages": self._max_messages or 1},
+ headers=headers,
+ timeout=aiohttp.ClientTimeout(total=_GC_PULL_TIMEOUT),
+ ) as resp:
+ if resp.status != 200:
+ body = await resp.text()
+ _GC_LOG.warning(
+ "[GoogleChat][NemoClaw] :pull HTTP %s: %s",
+ resp.status,
+ body[:200],
+ )
+ await asyncio.sleep(3)
+ continue
+ payload = await resp.json()
+ # Read and materialise the envelope here, so a body that is
+ # not an object and a receivedMessages that is not iterable
+ # are both failed pulls rather than escaping exceptions.
+ received = list(payload.get("receivedMessages") or [])
+ except asyncio.CancelledError:
+ raise
+ except Exception as exc: # noqa: BLE001 - transient pull errors are retried
+ _GC_LOG.warning("[GoogleChat][NemoClaw] :pull error: %s", exc)
+ await asyncio.sleep(3)
+ continue
+
+ if not received:
+ await asyncio.sleep(0.2) # guard against fast-empty long-poll returns
+ continue
+
+ acks: list = []
+ for received_message in received:
+ ack_id = None
+ try:
+ ack_id = received_message.get("ackId")
+ shim = _RestPubsubMessage(
+ received_message.get("message") or {}, acks, ack_id
+ )
+ except Exception: # noqa: BLE001 - a corrupt delivery ends nothing
+ # Redelivery cannot repair the same undecodable bytes, so
+ # retire it the way the bundled handler retires an
+ # unparseable envelope. Otherwise it may be redelivered
+ # repeatedly, and GOOGLE_CHAT_MAX_MESSAGES defaults to 1,
+ # so each redelivery costs a whole pull response.
+ _GC_LOG.exception(
+ "[GoogleChat][NemoClaw] retiring an unreadable delivery"
+ )
+ if ack_id:
+ acks.append(ack_id)
+ continue
+ try:
+ await asyncio.to_thread(self._on_pubsub_message, shim)
+ except Exception: # noqa: BLE001 - one bad message must not kill the loop
+ # Nothing is added to acks here: the handler acknowledges
+ # for itself, and unlike an unreadable payload its failure
+ # can be transient, so redelivery stays the default.
+ _GC_LOG.exception("[GoogleChat][NemoClaw] message handler raised")
+
+ if acks:
+ try:
+ async with session.post(
+ ack_url,
+ json={"ackIds": acks},
+ headers=headers,
+ timeout=aiohttp.ClientTimeout(total=30),
+ ) as ack_resp:
+ if ack_resp.status != 200:
+ _GC_LOG.warning(
+ "[GoogleChat][NemoClaw] :acknowledge HTTP %s",
+ ack_resp.status,
+ )
+ except Exception as exc: # noqa: BLE001 - a failed ack just redelivers
+ _GC_LOG.warning("[GoogleChat][NemoClaw] :acknowledge error: %s", exc)
+
+
+ return SandboxGoogleChatAdapter
+
+
+def install(ctx) -> None:
+ """Replace the registered google_chat entry with the sandbox delta."""
+ del ctx
+ try:
+ from gateway.platform_registry import platform_registry
+ except Exception: # noqa: BLE001 - keep plugin load resilient
+ _GC_LOG.exception("[GoogleChat][NemoClaw] platform_registry import failed")
+ return
+
+ entry = platform_registry.get("google_chat")
+ if entry is None:
+ _GC_LOG.error(
+ "[GoogleChat][NemoClaw] no google_chat platform entry; leaving Hermes untouched"
+ )
+ return
+
+ try:
+ adapter_class = _sandbox_adapter_class()
+ except Exception: # noqa: BLE001 - a failed import must not break other platforms
+ _GC_LOG.exception("[GoogleChat][NemoClaw] building the sandbox adapter failed")
+ return
+
+ platform_registry.register(
+ dataclasses.replace(
+ entry,
+ adapter_factory=adapter_class,
+ check_fn=_nemoclaw_gc_check,
+ required_env=["GOOGLE_CHAT_SUBSCRIPTION_NAME"],
+ )
+ )
+ _GC_LOG.info(
+ "[GoogleChat][NemoClaw] google_chat adapter replaced with the sandbox delta "
+ "(bundled entry metadata preserved)"
+ )
diff --git a/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.test.ts b/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.test.ts
new file mode 100644
index 00000000000..d279f821499
--- /dev/null
+++ b/src/lib/messaging/channels/googlechat/runtime/hermes-adapter.test.ts
@@ -0,0 +1,261 @@
+// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+const ADAPTER = path.join(path.dirname(fileURLToPath(import.meta.url)), "hermes-adapter.py");
+const PLACEHOLDER = "openshell:resolve:env:GOOGLE_CHAT_ACCESS_TOKEN";
+const SUBSCRIPTION = "projects/nemoclaw-test/subscriptions/hermes-chat";
+
+// Stand-ins for the two imports the override reaches for at runtime. The bundled
+// Hermes adapter and aiohttp both live in the sandbox image, so the checked-in
+// test supplies the smallest surface `_rest_pull` touches.
+const HERMES_STUB = `
+class GoogleChatAdapter:
+ """Only what the subclass definition needs; _rest_pull calls none of it."""
+
+
+class AuthorizedHttp:
+ def __init__(self, credentials, http=None):
+ self.credentials = credentials
+ self.http = http
+`;
+
+const AIOHTTP_STUB = `
+"""Recording aiohttp double. Every request lands in REQUESTS; responses are scripted."""
+
+REQUESTS = []
+SCRIPT = []
+
+
+class ClientTimeout:
+ def __init__(self, total=None):
+ self.total = total
+
+
+class _Response:
+ def __init__(self, status, payload):
+ self.status, self._payload = status, payload or {}
+
+ async def json(self):
+ return self._payload
+
+ async def text(self):
+ return ""
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc_info):
+ return False
+
+
+class ClientSession:
+ def __init__(self, *args, **kwargs):
+ self.kwargs = kwargs
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc_info):
+ return False
+
+ def post(self, url, json=None, headers=None, timeout=None):
+ REQUESTS.append(
+ {"url": url, "method": "POST", "authorization": (headers or {}).get("Authorization"), "body": json}
+ )
+ # Raise rather than assert: python -O strips assert statements, and an
+ # inherited PYTHONOPTIMIZE would silently turn the refusal below into an
+ # ordinary non-200 response, passing the test without its claim.
+ if not SCRIPT:
+ raise AssertionError("aiohttp double ran out of scripted responses: " + url)
+ status, payload, on_send = SCRIPT.pop(0)
+ on_send and on_send()
+ if status == "transport-error":
+ raise ConnectionError("proxy refused the acknowledge")
+ return _Response(status, payload)
+`;
+
+// Drives the real _rest_pull against the doubles above and prints what crossed the
+// wire. Scenario names match the test titles below.
+const DRIVER = `
+import asyncio
+import base64
+import importlib.util
+import json
+import sys
+
+import aiohttp
+
+ADAPTER_PATH, SCENARIO = sys.argv[1], sys.argv[2]
+SUBSCRIPTION = ${JSON.stringify(SUBSCRIPTION)}
+
+spec = importlib.util.spec_from_file_location("nemoclaw_googlechat_adapter", ADAPTER_PATH)
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+adapter = object.__new__(module._sandbox_adapter_class())
+adapter._sandbox_subscription = SUBSCRIPTION
+adapter._shutting_down = False
+adapter._max_messages = 1
+
+handled = []
+
+
+def _received(ack_id, text):
+ message = {"data": base64.b64encode(text.encode()).decode(), "attributes": {}}
+ return {"receivedMessages": [{"ackId": ack_id, "message": message}]}
+
+
+def _stop():
+ adapter._shutting_down = True
+
+
+def _handler(message):
+ handled.append(message.data.decode())
+ message.nack() if SCENARIO == "nack" else message.ack()
+
+
+adapter._on_pubsub_message = _handler
+delivery, redelivery = _received("ack-1", "hello"), _received("ack-1", "hello")
+
+# Two corrupted deliveries: an entry that is not a mapping at all, and one whose
+# base64 cannot be decoded. Both raise while the message is shaped, and only the
+# second carries an ack id to retire it with. The scenario also opens with a pull
+# body whose receivedMessages is not iterable, which fails both on the read and on
+# the loop over it; an empty container would be coerced to {} by the double and
+# prove nothing.
+malformed = {"receivedMessages": [None, {"ackId": "ack-bad", "message": {"data": "abcde"}}]}
+
+# Each entry is (pull or acknowledge response status, payload, side effect).
+# A rejected acknowledge means Pub/Sub redelivers the same ackId on the next pull.
+aiohttp.SCRIPT.extend(
+ {
+ "acknowledged": [(200, delivery, None), (200, {}, _stop)],
+ "acknowledge-fails": [
+ (200, delivery, None), (500, {}, None), (200, redelivery, None), (200, {}, _stop),
+ ],
+ "acknowledge-raises": [
+ (200, delivery, None),
+ ("transport-error", None, None),
+ (200, redelivery, None),
+ (200, {}, _stop),
+ ],
+ "malformed-payload": [
+ (200, {"receivedMessages": 1}, None), (200, malformed, None), (200, {}, None),
+ (200, delivery, None), (200, {}, _stop),
+ ],
+ "nack": [(200, delivery, _stop)],
+ }[SCENARIO]
+)
+
+asyncio.run(adapter._rest_pull())
+
+print(json.dumps({"requests": aiohttp.REQUESTS, "handled": handled}))
+`;
+
+interface RecordedRequest {
+ readonly url: string;
+ readonly method: string;
+ readonly authorization: string | null;
+ readonly body: Record | null;
+}
+
+const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-googlechat-pull-"));
+
+function runScenario(scenario: string): { requests: RecordedRequest[]; handled: string[] } {
+ const result = spawnSync("python3", [path.join(workspace, "driver.py"), ADAPTER, scenario], {
+ encoding: "utf8",
+ env: { ...process.env, PYTHONPATH: workspace, PYTHONDONTWRITEBYTECODE: "1" },
+ timeout: 30_000,
+ });
+ expect(result.status, `${result.stdout}${result.stderr}`).toBe(0);
+ return JSON.parse(result.stdout.trim().split("\n").at(-1) ?? "");
+}
+
+describe("Hermes Google Chat keyless REST pull", () => {
+ beforeAll(() => {
+ const bundled = path.join(workspace, "plugins", "platforms", "google_chat");
+ fs.mkdirSync(bundled, { recursive: true });
+ fs.writeFileSync(path.join(workspace, "plugins", "__init__.py"), "");
+ fs.writeFileSync(path.join(workspace, "plugins", "platforms", "__init__.py"), "");
+ fs.writeFileSync(path.join(bundled, "__init__.py"), "");
+ fs.writeFileSync(path.join(bundled, "adapter.py"), HERMES_STUB);
+ fs.writeFileSync(path.join(workspace, "aiohttp.py"), AIOHTTP_STUB);
+ fs.writeFileSync(path.join(workspace, "driver.py"), DRIVER);
+ });
+
+ afterAll(() => {
+ fs.rmSync(workspace, { recursive: true, force: true });
+ });
+
+ it("sends only the credential placeholder, and only to pull and acknowledge", () => {
+ const { requests, handled } = runScenario("acknowledged");
+
+ expect(handled).toEqual(["hello"]);
+ expect(new Set(requests.map((request) => request.authorization))).toEqual(
+ new Set([`Bearer ${PLACEHOLDER}`]),
+ );
+ expect(new Set(requests.map((request) => `${request.method} ${request.url}`))).toEqual(
+ new Set([
+ `POST https://pubsub.googleapis.com/v1/${SUBSCRIPTION}:pull`,
+ `POST https://pubsub.googleapis.com/v1/${SUBSCRIPTION}:acknowledge`,
+ ]),
+ );
+ expect(
+ requests.filter((request) => request.url.endsWith(":acknowledge")).map((r) => r.body),
+ ).toEqual([{ ackIds: ["ack-1"] }]);
+ });
+
+ it("keeps a message eligible for redelivery when acknowledgement fails", () => {
+ const { requests, handled } = runScenario("acknowledge-fails");
+
+ // The rejected acknowledgement neither raises nor ends the pull, so the
+ // redelivered copy is handled again instead of being lost.
+ expect(handled).toEqual(["hello", "hello"]);
+ expect(requests.filter((request) => request.url.endsWith(":pull"))).toHaveLength(2);
+ expect(
+ requests
+ .filter((request) => request.url.endsWith(":acknowledge"))
+ .map((request) => request.body),
+ ).toEqual([{ ackIds: ["ack-1"] }, { ackIds: ["ack-1"] }]);
+ });
+
+ it("keeps pulling when the acknowledge transport itself fails", () => {
+ const { requests, handled } = runScenario("acknowledge-raises");
+
+ // A rejected connection must not escape _rest_pull; letting it end the loop
+ // would stop inbound delivery for the whole session, not just this message.
+ expect(handled).toEqual(["hello", "hello"]);
+ expect(requests.filter((request) => request.url.endsWith(":pull"))).toHaveLength(2);
+ });
+
+ it("retires an undecodable delivery and survives a malformed pull response", () => {
+ const { requests, handled } = runScenario("malformed-payload");
+
+ // Reading the response envelope and shaping each message both raise here:
+ // receivedMessages is not iterable, one entry is not a mapping, and one payload
+ // is not decodable. Any of them ending the pull would silence inbound for the
+ // rest of the session, and leaving the entry that has an ack id unacknowledged
+ // would permit repeated poison-message redelivery.
+ expect(handled).toEqual(["hello"]);
+ expect(requests.filter((request) => request.url.endsWith(":pull"))).toHaveLength(3);
+ expect(
+ requests
+ .filter((request) => request.url.endsWith(":acknowledge"))
+ .map((request) => request.body),
+ ).toEqual([{ ackIds: ["ack-bad"] }, { ackIds: ["ack-1"] }]);
+ });
+
+ it("acknowledges nothing for a message the handler nacks", () => {
+ const { requests, handled } = runScenario("nack");
+
+ expect(handled).toEqual(["hello"]);
+ expect(requests.filter((request) => request.url.endsWith(":acknowledge"))).toEqual([]);
+ });
+});
diff --git a/src/lib/messaging/channels/googlechat/template-resolver.test.ts b/src/lib/messaging/channels/googlechat/template-resolver.test.ts
index 85cb3d53307..2e0e454235e 100644
--- a/src/lib/messaging/channels/googlechat/template-resolver.test.ts
+++ b/src/lib/messaging/channels/googlechat/template-resolver.test.ts
@@ -74,6 +74,42 @@ describe("Google Chat template resolver", () => {
).toBeUndefined();
});
+ it("resolves the Hermes pull config + CSV allowlist; drops each when unset", () => {
+ const set: SandboxMessagingInputReference[] = [
+ configInput("projectId", "googlechatConfig.projectId", "nemoclaw-project-500901"),
+ configInput(
+ "subscriptionName",
+ "googlechatConfig.subscriptionName",
+ "projects/nemoclaw-project-500901/subscriptions/hermes-chat-events-sub",
+ ),
+ configInput("allowFrom", "allowedIds.googlechat", "users/111, users/222"),
+ ];
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.projectId", { inputs: set })?.value,
+ ).toBe("nemoclaw-project-500901");
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.subscriptionName", { inputs: set })
+ ?.value,
+ ).toBe("projects/nemoclaw-project-500901/subscriptions/hermes-chat-events-sub");
+ expect(
+ resolveGooglechatTemplateReference("allowedIds.googlechat.csv", { inputs: set })?.value,
+ ).toBe("users/111,users/222");
+
+ // Unset → undefined so the render engine drops the env line (no literal
+ // "{{...}}" leaks into ~/.hermes/.env).
+ const empty: SandboxMessagingInputReference[] = [];
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.projectId", { inputs: empty })?.value,
+ ).toBeUndefined();
+ expect(
+ resolveGooglechatTemplateReference("googlechatConfig.subscriptionName", { inputs: empty })
+ ?.value,
+ ).toBeUndefined();
+ expect(
+ resolveGooglechatTemplateReference("allowedIds.googlechat.csv", { inputs: empty })?.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
index 5d39ae69e13..b07ac0d9184 100644
--- a/src/lib/messaging/channels/googlechat/template-resolver.ts
+++ b/src/lib/messaging/channels/googlechat/template-resolver.ts
@@ -43,6 +43,17 @@ export const resolveGooglechatTemplateReference: BuiltInRenderTemplateResolver =
nonEmptyString(stateValue(context, "googlechatConfig.appPrincipal")) ??
APP_PRINCIPAL_DISCOVERY_SENTINEL,
);
+ // Hermes Pub/Sub pull config, rendered into ~/.hermes/.env. Undefined when
+ // unset so the render engine drops the line entirely rather than emitting a
+ // literal "{{...}}" placeholder for the adapter to read as a subscription.
+ case "googlechatConfig.projectId":
+ return resolvedRenderTemplateReference(
+ nonEmptyString(stateValue(context, "googlechatConfig.projectId")),
+ );
+ case "googlechatConfig.subscriptionName":
+ return resolvedRenderTemplateReference(
+ nonEmptyString(stateValue(context, "googlechatConfig.subscriptionName")),
+ );
default:
break;
}
@@ -51,7 +62,7 @@ export const resolveGooglechatTemplateReference: BuiltInRenderTemplateResolver =
// `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)$/);
+ const allowReference = reference.match(/^allowedIds[.]googlechat[.](values|dmPolicy|csv)$/);
if (!allowReference?.[1]) return undefined;
const ids = allowedIds(context, "googlechat");
switch (allowReference[1]) {
@@ -59,6 +70,11 @@ export const resolveGooglechatTemplateReference: BuiltInRenderTemplateResolver =
return resolvedRenderTemplateReference(nonEmptyArray(ids));
case "dmPolicy":
return resolvedRenderTemplateReference(ids.length > 0 ? "allowlist" : undefined);
+ case "csv":
+ // Hermes reads GOOGLE_CHAT_ALLOWED_USERS as a comma-separated env value.
+ // Undefined when empty so the line drops and the adapter's own default-deny
+ // (unknown senders rejected) governs, not an empty allowlist string.
+ return resolvedRenderTemplateReference(ids.length > 0 ? ids.join(",") : undefined);
default:
return undefined;
}
diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts
index fa0ce9205ba..18f267c170c 100644
--- a/src/lib/messaging/channels/manifests.test.ts
+++ b/src/lib/messaging/channels/manifests.test.ts
@@ -17,7 +17,6 @@ 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);
@@ -25,7 +24,7 @@ describe("built-in channel manifests", () => {
channelNames,
);
expect(registry.listAvailable({ agent: "hermes" }).map((manifest) => manifest.id)).toEqual(
- hermesChannelNames,
+ channelNames,
);
});
diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts
index e4ddb6e21f9..8bcd687be16 100644
--- a/src/lib/messaging/channels/metadata.test.ts
+++ b/src/lib/messaging/channels/metadata.test.ts
@@ -43,6 +43,7 @@ describe("built-in messaging channel metadata", () => {
"slack",
"whatsapp",
"teams",
+ "googlechat",
]);
});
@@ -124,6 +125,8 @@ describe("built-in messaging channel metadata", () => {
"GOOGLECHAT_AUDIENCE",
"GOOGLECHAT_APP_PRINCIPAL",
"GOOGLECHAT_ALLOWED_USERS",
+ "GOOGLE_CHAT_PROJECT_ID",
+ "GOOGLE_CHAT_SUBSCRIPTION_NAME",
]);
expect(getMessagingConfigEnvAliases()).toEqual({
DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"],
@@ -214,6 +217,27 @@ describe("built-in messaging channel metadata", () => {
manager: "hermes-uv-pip",
spec: "aiohttp==3.14.3",
},
+ {
+ channelId: "googlechat",
+ packageId: "hermesGooglePubsubPackage",
+ agents: ["hermes"],
+ manager: "hermes-uv-pip",
+ spec: "google-cloud-pubsub==2.39.0",
+ },
+ {
+ channelId: "googlechat",
+ packageId: "hermesGoogleApiClientPackage",
+ agents: ["hermes"],
+ manager: "hermes-uv-pip",
+ spec: "google-api-python-client==2.194.0",
+ },
+ {
+ channelId: "googlechat",
+ packageId: "hermesGoogleAuthPackage",
+ agents: ["hermes"],
+ manager: "hermes-uv-pip",
+ spec: "google-auth==2.55.1",
+ },
]);
});
diff --git a/src/lib/messaging/utils.test.ts b/src/lib/messaging/utils.test.ts
index f423466bfb7..17df8e59481 100644
--- a/src/lib/messaging/utils.test.ts
+++ b/src/lib/messaging/utils.test.ts
@@ -42,6 +42,7 @@ describe("listSupportedMessagingChannelIdsForAgent", () => {
"slack",
"whatsapp",
"teams",
+ "googlechat",
]);
});
});
diff --git a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts
index 2c7131c94a8..2e2c8e93d20 100644
--- a/src/lib/onboard/experimental/hermes-portable-build-context-files.ts
+++ b/src/lib/onboard/experimental/hermes-portable-build-context-files.ts
@@ -205,7 +205,10 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [
{ path: "src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.test.ts", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/hooks/tunnel-runtime.ts", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/manifest.ts", mode: "100644" },
+ { path: "src/lib/messaging/channels/googlechat/policy.test.ts", mode: "100644" },
+ { path: "src/lib/messaging/channels/googlechat/policy/hermes.yaml", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/policy/openclaw.yaml", mode: "100644" },
+ { path: "src/lib/messaging/channels/googlechat/provider-profile/hermes.yaml", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/provider-profile/openclaw.yaml", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/rendered-config-parser.ts", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/runtime-contract.test.ts", mode: "100644" },
@@ -225,6 +228,8 @@ export const HERMES_PORTABLE_BUILD_CONTEXT_FILES = [
path: "src/lib/messaging/channels/googlechat/runtime/googlechat-trusted-proxy-fetch.ts",
mode: "100644",
},
+ { path: "src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py", mode: "100644" },
+ { path: "src/lib/messaging/channels/googlechat/runtime/hermes-adapter.test.ts", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/template-resolver.test.ts", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/template-resolver.ts", mode: "100644" },
{ path: "src/lib/messaging/channels/googlechat/tunnel/lifecycle.test.ts", mode: "100644" },
diff --git a/src/lib/onboard/experimental/hermes-portable-build-context.ts b/src/lib/onboard/experimental/hermes-portable-build-context.ts
index 499a5994f8a..3796568d694 100644
--- a/src/lib/onboard/experimental/hermes-portable-build-context.ts
+++ b/src/lib/onboard/experimental/hermes-portable-build-context.ts
@@ -83,6 +83,7 @@ const LOCAL_COPY_SOURCES = [
"src/lib/actions/sandbox/openshell-child-visible-credentials.v0.0.106.json",
"src/lib/hermes-managed-route.ts",
"src/lib/messaging/",
+ "src/lib/messaging/channels/googlechat/runtime/hermes-adapter.py",
"src/lib/tool-disclosure.ts",
"tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/managed-startup-image-runtime.bundle",
"tools/mcp-tool-discovery-runtime/reviewed-runtime-bundle/mcp-tool-discovery/BUNDLED_PACKAGES.json",
diff --git a/src/lib/onboard/messaging-bridge-provider.test.ts b/src/lib/onboard/messaging-bridge-provider.test.ts
index 56585f4684c..b60fc1caf1c 100644
--- a/src/lib/onboard/messaging-bridge-provider.test.ts
+++ b/src/lib/onboard/messaging-bridge-provider.test.ts
@@ -37,6 +37,21 @@ const GC_PROFILE: MessagingBridgeProfile = {
sourceSecretEnv: "GOOGLECHAT_SERVICE_ACCOUNT",
};
+// Google Chat is the one channel shipping a profile per agent, so a sandbox must
+// pick exactly one. Hermes also needs pubsub on top of chat.bot: one token, both
+// scopes, because `:pull` 403s without it.
+const GC_HERMES_PROFILE: MessagingBridgeProfile = {
+ ...GC_PROFILE,
+ agent: "hermes",
+ profilePath: "/repo/src/lib/messaging/channels/googlechat/provider-profile/hermes.yaml",
+ profileId: "google-chat-bridge-hermes",
+};
+
+const GC_PUBSUB_SCOPES = [
+ "https://www.googleapis.com/auth/chat.bot",
+ "https://www.googleapis.com/auth/pubsub",
+];
+
const BRIDGE_DEF = {
name: "sbx-googlechat-bridge",
providerType: GC_PROFILE.profileId,
@@ -48,6 +63,7 @@ function collectInput(
) {
return {
sandboxName: "sbx",
+ agent: GC_PROFILE.agent,
getCredential: () => null,
enabledChannels: ["googlechat"],
disabledChannelNames: new Set(),
@@ -93,6 +109,20 @@ describe("collectMessagingBridgeTokenDefs", () => {
);
});
+ it("emits only the profile whose agent matches the sandbox", () => {
+ // Both profiles carry the same channelId, so filtering on the channel alone
+ // would configure the OpenClaw bridge on a Hermes sandbox and the reverse.
+ const defs = collectMessagingBridgeTokenDefs(
+ collectInput({
+ agent: "hermes",
+ getCredential: () => SA_JSON,
+ profiles: [GC_PROFILE, GC_HERMES_PROFILE],
+ }),
+ );
+
+ expect(defs.map((def) => def.providerType)).toEqual([GC_HERMES_PROFILE.profileId]);
+ });
+
it("emits the bridge token def from an env-only secret (resolution parity)", () => {
const defs = collectMessagingBridgeTokenDefs(
collectInput({
@@ -194,6 +224,27 @@ describe("configureMessagingBridgeRefreshes", () => {
expect(process.env[secretEnvName]).toBe(parentSecret);
});
+ it("mints one token carrying every scope the profile declares", () => {
+ // Hermes reads Pub/Sub and writes Chat with the same minted token, so sending
+ // only the first scope leaves `:pull` rejected with 403 at runtime.
+ 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, scopes: GC_PUBSUB_SCOPES }],
+ });
+
+ expect(result).toEqual({ ok: true });
+ const args = runOpenshell.mock.calls[0][0];
+ expect(args).toContain(`scope=${GC_PUBSUB_SCOPES.join(" ")}`);
+ expect(args).not.toContain(`scope=${GC_PUBSUB_SCOPES[0]}`);
+ });
+
it("forces private_key off argv even when the profile omits it from secretMaterialKeys", () => {
// A misconfigured / edited / reused profile that marks other material secret
// but not private_key must still never leak the raw key into argv.
diff --git a/src/lib/onboard/messaging-bridge-provider.ts b/src/lib/onboard/messaging-bridge-provider.ts
index 2f43465aba2..4dedead32e7 100644
--- a/src/lib/onboard/messaging-bridge-provider.ts
+++ b/src/lib/onboard/messaging-bridge-provider.ts
@@ -87,6 +87,12 @@ export interface MessagingBridgeSecretResolveDeps {
export interface CollectMessagingBridgeTokenDefsInput extends MessagingBridgeSecretResolveDeps {
readonly sandboxName: string;
+ /**
+ * Recorded sandbox agent, unnormalized. Bridge profiles are per-agent and a
+ * channel may ship both (Google Chat does), so the profile filter selects the
+ * matching one and rejects an agent no profile declares.
+ */
+ readonly agent: string | null | undefined;
readonly enabledChannels: readonly string[] | null;
readonly disabledChannelNames: ReadonlySet;
/** Injected for tests; defaults to convention discovery. */
@@ -255,7 +261,7 @@ function bridgeProviderNameFor(sandboxName: string, channelId: string): string {
export function collectMessagingBridgeTokenDefs(
input: CollectMessagingBridgeTokenDefsInput,
): { name: string; envKey: string; token: string; providerType: string }[] {
- const profiles = input.profiles ?? listMessagingBridgeProfiles();
+ const profiles = messagingBridgeProfilesForAgent(input.agent, input.profiles);
const defs: { name: string; envKey: string; token: string; providerType: string }[] = [];
for (const profile of profiles) {
if (input.disabledChannelNames.has(profile.channelId)) continue;
@@ -273,6 +279,19 @@ export function collectMessagingBridgeTokenDefs(
return defs;
}
+/**
+ * Single authority for which bridge profiles an agent may use. An unset agent is
+ * OpenClaw, matching `toMessagingAgentId`; a recorded agent no profile declares
+ * selects nothing, so it mints and reuses no bridge.
+ */
+export function messagingBridgeProfilesForAgent(
+ agent: string | null | undefined,
+ profiles: readonly MessagingBridgeProfile[] = listMessagingBridgeProfiles(),
+): MessagingBridgeProfile[] {
+ const name = agent?.trim().toLowerCase() || "openclaw";
+ return profiles.filter((profile) => profile.agent === name);
+}
+
/**
* Gateway-minted bridge provider name(s) for a channel — the providers
* `channels remove` must tear down. A bridge-backed channel has no
@@ -399,8 +418,12 @@ function buildRefreshMaterial(
{ 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] });
+ // Join every declared scope space-separated so ONE minted token carries all
+ // of them. Hermes Google Chat needs chat.bot AND pubsub in a single
+ // credential; taking only scopes[0] made `:pull` fail with 403.
+ if (profile.scopes.length > 0) {
+ material.push({ key: "scope", value: profile.scopes.join(" ") });
+ }
// This strategy always emits private_key as material, so force it into the
// secret set (delivered via --secret-material-env, never argv) regardless of
// what the profile declares. A profile whose secretMaterialKeys omitted it
diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts
index 2a5d52f4747..06e84b882bf 100644
--- a/src/lib/onboard/messaging-prep.test.ts
+++ b/src/lib/onboard/messaging-prep.test.ts
@@ -78,12 +78,18 @@ describe("prepareCreateSandboxMessaging", () => {
// 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");
+ // The gateway holds the OpenClaw binding, which is the agent being onboarded.
+ const providerMatchesGatewayCredential = vi.fn(
+ (name: string, type: string, credentialKey: string) =>
+ name === "demo-googlechat-bridge" &&
+ type === "google-chat-bridge" &&
+ credentialKey === "GOOGLE_CHAT_ACCESS_TOKEN",
+ );
const result = prepareCreateSandboxMessaging(
createInput({
enabledChannels: ["googlechat"],
- providerExistsInGateway,
+ providerMatchesGatewayCredential,
}),
);
@@ -92,10 +98,67 @@ describe("prepareCreateSandboxMessaging", () => {
);
expect(result.reusableMessagingProviders).toContain("demo-googlechat-bridge");
expect(result.reusableMessagingChannels).toContain("googlechat");
- expect(providerExistsInGateway).toHaveBeenCalledWith("demo-googlechat-bridge");
+ expect(providerMatchesGatewayCredential).toHaveBeenCalledWith(
+ "demo-googlechat-bridge",
+ "google-chat-bridge",
+ "GOOGLE_CHAT_ACCESS_TOKEN",
+ );
+ });
+
+ it("refuses and reports a bridge the gateway holds for a different agent", () => {
+ // onboard recreates a sandbox name under a new agent (`Delete and recreate
+ // '' as ?`), and the provider name carries no agent. Reusing the
+ // stale binding would mint the previous agent's token — for Hermes that means
+ // an OpenClaw profile whose scopes omit pubsub, so `:pull` fails with 403.
+ // Refusing it also has to surface, or the channel silently leaves the intent.
+ const result = prepareCreateSandboxMessaging(
+ createInput({
+ agentName: "hermes",
+ enabledChannels: ["googlechat"],
+ providerMatchesGatewayCredential: (_name: string, type: string) =>
+ type === "google-chat-bridge",
+ }),
+ );
+
+ expect(result.reusableMessagingProviders).not.toContain("demo-googlechat-bridge");
+ expect(result.reusableMessagingChannels).not.toContain("googlechat");
+ expect(result.missingBridgeChannels).toEqual(["googlechat"]);
+ });
+
+ it("reuses a bridge the gateway holds for the agent being onboarded", () => {
+ const result = prepareCreateSandboxMessaging(
+ createInput({
+ agentName: "hermes",
+ enabledChannels: ["googlechat"],
+ providerMatchesGatewayCredential: (_name: string, type: string) =>
+ type === "google-chat-hermes-bridge",
+ }),
+ );
+
+ expect(result.reusableMessagingProviders).toContain("demo-googlechat-bridge");
+ expect(result.reusableMessagingChannels).toContain("googlechat");
+ expect(result.missingBridgeChannels).toEqual([]);
+ });
+
+ it("does not reuse a Hermes bridge when onboarding OpenClaw", () => {
+ const gatewayHoldsHermesBinding = vi.fn(
+ (_name: string, type: string) => type === "google-chat-hermes-bridge",
+ );
+
+ const result = prepareCreateSandboxMessaging(
+ createInput({
+ enabledChannels: ["googlechat"],
+ providerMatchesGatewayCredential: gatewayHoldsHermesBinding,
+ }),
+ );
+
+ expect(result.reusableMessagingProviders).not.toContain("demo-googlechat-bridge");
+ expect(result.reusableMessagingChannels).not.toContain("googlechat");
});
it("routes the bridge through upsert instead of reuse when the secret is resolvable", () => {
+ const providerMatchesGatewayCredential = vi.fn(() => true);
+
const result = prepareCreateSandboxMessaging(
createInput({
enabledChannels: ["googlechat"],
@@ -105,37 +168,73 @@ describe("prepareCreateSandboxMessaging", () => {
private_key: "fake-test-private-key-material",
}),
},
- providerExistsInGateway: () => true,
+ providerMatchesGatewayCredential,
}),
);
const def = result.messagingTokenDefs.find((d) => d.name === "demo-googlechat-bridge");
expect(def?.token).toBeTruthy();
expect(result.reusableMessagingProviders).not.toContain("demo-googlechat-bridge");
+ expect(result.missingBridgeChannels).toEqual([]);
+ // The token definition already owns the provider, so reuse never asks the
+ // gateway — a matcher that would have said yes is never consulted.
+ expect(providerMatchesGatewayCredential).not.toHaveBeenCalled();
+ });
+
+ it("configures no bridge for an agent no channel manifest supports", () => {
+ // Defaulting an unknown agent to OpenClaw would hand a sandbox with no
+ // messaging support the OpenClaw Google Chat bridge and its credential.
+ const result = prepareCreateSandboxMessaging(
+ createInput({
+ agentName: "deepagents",
+ enabledChannels: ["googlechat"],
+ env: {
+ GOOGLECHAT_SERVICE_ACCOUNT: JSON.stringify({
+ client_email: "bot@p.iam.gserviceaccount.com",
+ private_key: "fake-test-private-key-material",
+ }),
+ },
+ }),
+ );
+
+ expect(result.messagingTokenDefs.map((def) => def.name)).not.toContain(
+ "demo-googlechat-bridge",
+ );
+ // The provider name derives from the channel alone, so reuse has to be gated
+ // as well; otherwise the sandbox adopts whichever bridge already exists.
+ expect(result.reusableMessagingProviders).not.toContain("demo-googlechat-bridge");
+ expect(result.reusableMessagingChannels).not.toContain("googlechat");
});
- it("does not reuse a bridge provider that is absent from the gateway", () => {
+ it("does not reuse a bridge provider without an exact gateway binding", () => {
const result = prepareCreateSandboxMessaging(
createInput({
enabledChannels: ["googlechat"],
- providerExistsInGateway: () => false,
+ providerMatchesGatewayCredential: () => false,
}),
);
expect(result.reusableMessagingProviders).toEqual([]);
expect(result.reusableMessagingChannels).toEqual([]);
+ expect(result.missingBridgeChannels).toEqual(["googlechat"]);
});
it("does not reuse the bridge provider of a disabled channel", () => {
+ // The matcher would accept this provider, so only the disabled guard can
+ // keep it out — and a disabled channel is not a missing one either.
+ const providerMatchesGatewayCredential = vi.fn(() => true);
+
const result = prepareCreateSandboxMessaging(
createInput({
enabledChannels: ["googlechat"],
disabledChannels: ["googlechat"],
- providerExistsInGateway: () => true,
+ providerMatchesGatewayCredential,
}),
);
expect(result.reusableMessagingProviders).toEqual([]);
+ expect(result.missingBridgeChannels).toEqual([]);
+ expect(providerMatchesGatewayCredential).not.toHaveBeenCalled();
});
it("reports missing Brave API keys before registering extra placeholder providers", () => {
diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts
index d0dd50eae56..776f3370647 100644
--- a/src/lib/onboard/messaging-prep.ts
+++ b/src/lib/onboard/messaging-prep.ts
@@ -9,6 +9,7 @@ import * as braveProviderProfile from "./brave-provider-profile";
import {
bridgeProviderNamesForChannel,
collectMessagingBridgeTokenDefs,
+ messagingBridgeProfilesForAgent,
} from "./messaging-bridge-provider";
export type NamedMessagingChannel = { name: string } & ChannelDef;
@@ -51,6 +52,8 @@ export interface CreateSandboxMessagingPrepResult {
hasMessagingTokens: boolean;
reusableMessagingProviders: string[];
reusableMessagingChannels: string[];
+ /** Selected bridge channels with no usable provider and no source secret. */
+ missingBridgeChannels: string[];
missingWebSearchCredentialEnv: string | null;
}
@@ -119,6 +122,7 @@ export function prepareCreateSandboxMessaging(
hasMessagingTokens: messagingTokenDefs.some(({ token }) => !!token),
reusableMessagingProviders: [],
reusableMessagingChannels: [],
+ missingBridgeChannels: [],
missingWebSearchCredentialEnv,
};
}
@@ -138,9 +142,13 @@ export function prepareCreateSandboxMessaging(
// 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.
+ // Resolve the agent instead of defaulting it: an agent no manifest supports
+ // must configure no bridge, not the OpenClaw one.
+ const bridgeProfiles = messagingBridgeProfilesForAgent(input.agentName);
messagingTokenDefs.push(
...collectMessagingBridgeTokenDefs({
sandboxName: input.sandboxName,
+ agent: input.agentName,
getCredential: input.getCredential,
env: input.env,
normalizeCredentialValue: input.normalizeCredentialValue,
@@ -178,13 +186,19 @@ 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.
+ // The name carries the channel but not the agent, and onboard can recreate a
+ // sandbox name under a different agent, so match the gateway binding against
+ // the selected profile rather than accepting any provider with that name.
if (input.enabledChannels != null) {
- for (const channel of input.enabledChannels) {
+ for (const profile of bridgeProfiles) {
+ const channel = profile.channelId;
+ if (!input.enabledChannels.includes(channel)) continue;
if (disabledChannelNames.has(channel)) continue;
- for (const name of bridgeProviderNamesForChannel(input.sandboxName, channel)) {
+ for (const name of bridgeProviderNamesForChannel(input.sandboxName, channel, [profile])) {
if (messagingTokenDefs.some((def) => def.name === name && def.token)) continue;
if (reusableMessagingProviders.includes(name)) continue;
- if (!input.providerExistsInGateway(name)) continue;
+ if (!input.providerMatchesGatewayCredential(name, profile.profileId, profile.credentialKey))
+ continue;
reusableMessagingProviders.push(name);
if (!reusableMessagingChannels.includes(channel)) {
reusableMessagingChannels.push(channel);
@@ -193,8 +207,27 @@ export function prepareCreateSandboxMessaging(
}
}
+ // A selected bridge channel that ends with neither a token def nor a matching
+ // gateway provider would otherwise vanish from the create intent, and onboard
+ // can act on that intent by deleting and recreating the sandbox. Report it so
+ // the caller can stop and ask for the source secret again.
+ const selectedChannels = input.enabledChannels;
+ const missingBridgeChannels =
+ selectedChannels == null
+ ? []
+ : [...new Set(bridgeProfiles.map((profile) => profile.channelId))].filter(
+ (channel) =>
+ selectedChannels.includes(channel) &&
+ !disabledChannelNames.has(channel) &&
+ !reusableMessagingChannels.includes(channel) &&
+ !bridgeProviderNamesForChannel(input.sandboxName, channel, bridgeProfiles).some(
+ (name) => messagingTokenDefs.some((def) => def.name === name && def.token),
+ ),
+ );
+
return {
disabledChannelNames,
+ missingBridgeChannels,
messagingTokenDefs,
extraPlaceholderKeys,
hasMessagingTokens,
diff --git a/src/lib/onboard/sandbox-messaging-preflight.test.ts b/src/lib/onboard/sandbox-messaging-preflight.test.ts
index c69ff7c829f..b2ab4a1c196 100644
--- a/src/lib/onboard/sandbox-messaging-preflight.test.ts
+++ b/src/lib/onboard/sandbox-messaging-preflight.test.ts
@@ -22,6 +22,7 @@ function createResult(overrides = {}) {
hasMessagingTokens: false,
reusableMessagingProviders: [],
reusableMessagingChannels: [],
+ missingBridgeChannels: [],
missingWebSearchCredentialEnv: null,
...overrides,
};
@@ -201,38 +202,39 @@ describe("prepareSandboxMessagingPreflight", () => {
it.each([
{ mode: "interactive", nonInteractive: false },
{ mode: "non-interactive", nonInteractive: true },
- ])("aborts an enabled channel with an unavailable credential hash before $mode onboarding (#7808)", async ({
- nonInteractive,
- }) => {
- const planWithoutHash = createPlan("demo", "discord", undefined);
- const incompletePlan = {
- ...planWithoutHash,
- credentialBindings: planWithoutHash.credentialBindings.map((binding) => {
- const { credentialHash: _credentialHash, ...bindingWithoutHash } = binding;
- return { ...bindingWithoutHash, credentialAvailable: false };
- }),
- };
- const listSandboxes = vi.fn(() => ({
- sandboxes: [
- { name: "other", messaging: { plan: createPlan("other", "discord", "other-hash") } },
- ],
- }));
- const deps = createDeps({
- readMessagingPlanFromEnv: vi.fn(() => incompletePlan),
- isNonInteractive: vi.fn(() => nonInteractive),
- registry: { listSandboxes },
- });
+ ])(
+ "aborts an enabled channel with an unavailable credential hash before $mode onboarding (#7808)",
+ async ({ nonInteractive }) => {
+ const planWithoutHash = createPlan("demo", "discord", undefined);
+ const incompletePlan = {
+ ...planWithoutHash,
+ credentialBindings: planWithoutHash.credentialBindings.map((binding) => {
+ const { credentialHash: _credentialHash, ...bindingWithoutHash } = binding;
+ return { ...bindingWithoutHash, credentialAvailable: false };
+ }),
+ };
+ const listSandboxes = vi.fn(() => ({
+ sandboxes: [
+ { name: "other", messaging: { plan: createPlan("other", "discord", "other-hash") } },
+ ],
+ }));
+ const deps = createDeps({
+ readMessagingPlanFromEnv: vi.fn(() => incompletePlan),
+ isNonInteractive: vi.fn(() => nonInteractive),
+ registry: { listSandboxes },
+ });
- await expect(prepareSandboxMessagingPreflight(baseInput, deps)).rejects.toMatchObject({
- code: 1,
- });
- expect(deps.error).toHaveBeenCalledWith(
- expect.stringContaining("credential hashes are unavailable for discord"),
- );
- expect(listSandboxes).not.toHaveBeenCalled();
- expect(deps.promptYesNoOrDefault).not.toHaveBeenCalled();
- expect(deps.prepareCreateSandboxMessaging).not.toHaveBeenCalled();
- });
+ await expect(prepareSandboxMessagingPreflight(baseInput, deps)).rejects.toMatchObject({
+ code: 1,
+ });
+ expect(deps.error).toHaveBeenCalledWith(
+ expect.stringContaining("credential hashes are unavailable for discord"),
+ );
+ expect(listSandboxes).not.toHaveBeenCalled();
+ expect(deps.promptYesNoOrDefault).not.toHaveBeenCalled();
+ expect(deps.prepareCreateSandboxMessaging).not.toHaveBeenCalled();
+ },
+ );
it("aborts a second Slack Socket Mode sandbox on the same gateway (#7808)", async () => {
let gatewayName = "startup-gateway";
@@ -283,6 +285,21 @@ describe("prepareSandboxMessagingPreflight", () => {
);
});
+ it("fails before recreate/delete when a selected bridge channel has no usable provider", async () => {
+ const deps = createDeps({
+ prepareCreateSandboxMessaging: vi.fn(() =>
+ createResult({ missingBridgeChannels: ["googlechat"] }),
+ ),
+ });
+
+ await expect(
+ prepareSandboxMessagingPreflight({ ...baseInput, enabledChannels: ["googlechat"] }, deps),
+ ).rejects.toMatchObject({ code: 1 });
+ expect(deps.error).toHaveBeenCalledWith(
+ " googlechat mints its outbound token gateway-side and needs GOOGLECHAT_SERVICE_ACCOUNT to configure it.",
+ );
+ });
+
it("names the selected Tavily credential when recreate preflight fails", async () => {
const deps = createDeps({
prepareCreateSandboxMessaging: vi.fn(() =>
diff --git a/src/lib/onboard/sandbox-messaging-preflight.ts b/src/lib/onboard/sandbox-messaging-preflight.ts
index f37ce284c87..8d525f1fe90 100644
--- a/src/lib/onboard/sandbox-messaging-preflight.ts
+++ b/src/lib/onboard/sandbox-messaging-preflight.ts
@@ -3,6 +3,7 @@
import type { WebSearchConfig } from "../inference/web-search";
import type { SandboxMessagingPlan } from "../messaging/manifest/types";
+import { bridgeSecretEnvsForChannel } from "./messaging-bridge-provider";
import {
enforceMessagingChannelConflicts as defaultEnforceMessagingChannelConflicts,
type MessagingConflictGuardDeps,
@@ -87,6 +88,19 @@ export async function prepareSandboxMessagingPreflight(
providerMatchesGatewayCredential: deps.providerMatchesGatewayCredential,
});
+ // Fail before the caller can act on this intent: onboard may delete and
+ // recreate the sandbox, and a selected channel that resolved to nothing would
+ // be dropped from the replacement without a word.
+ result.missingBridgeChannels.forEach((channel) => {
+ deps.error(
+ ` ${channel} mints its outbound token gateway-side and needs ${bridgeSecretEnvsForChannel(channel).join(", ")} to configure it.`,
+ );
+ deps.error(" Paste the secret at the enrollment prompt or export the env var, then re-run.");
+ });
+ if (result.missingBridgeChannels.length > 0) {
+ deps.exitProcess(1);
+ }
+
if (result.missingWebSearchCredentialEnv) {
const envKey = result.missingWebSearchCredentialEnv;
deps.error(` Web search is enabled, but ${envKey} is not available in this process.`);
diff --git a/src/lib/onboard/sandbox-provider-cleanup.ts b/src/lib/onboard/sandbox-provider-cleanup.ts
index f278cb7eccc..2b9d1a26257 100644
--- a/src/lib/onboard/sandbox-provider-cleanup.ts
+++ b/src/lib/onboard/sandbox-provider-cleanup.ts
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import { listMessagingProviderSuffixes } from "../messaging/channels";
+import { listMessagingBridgeProfiles } from "./messaging-bridge-provider";
import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../name-validation";
export {
@@ -67,6 +68,10 @@ export type SandboxRecreateCleanupDeps = DetachSandboxProvidersDeps & {
export const SANDBOX_PROVIDER_SUFFIXES = [
...listMessagingProviderSuffixes().map((suffix) => suffix.replace(/^-/, "")),
+ // Bridge-profile channels mint their provider outside the manifest credentials
+ // (nothing is delivered into the sandbox), so the credential-derived suffixes
+ // above miss them and destroy would leave the provider still minting a token.
+ ...new Set(listMessagingBridgeProfiles().map((profile) => `${profile.channelId}-bridge`)),
"brave-search",
"tavily-search",
] as readonly string[];
diff --git a/test/hermes-image-build-probes.test.ts b/test/hermes-image-build-probes.test.ts
index 22d52590b08..4d88925a461 100644
--- a/test/hermes-image-build-probes.test.ts
+++ b/test/hermes-image-build-probes.test.ts
@@ -23,6 +23,7 @@ const commands = [
"discord-reopen",
"gateway-process-identity",
"gateway-runtime-metadata",
+ "googlechat-override-seams",
"langfuse-credentials",
"neutral-platform-inertness",
"profile-policy",
diff --git a/test/managed-image-capability-union.test.ts b/test/managed-image-capability-union.test.ts
index 583262c7d05..1e3a7c8bf0c 100644
--- a/test/managed-image-capability-union.test.ts
+++ b/test/managed-image-capability-union.test.ts
@@ -50,6 +50,9 @@ describe("managed-image capability union", () => {
expect(collectManagedImageHermesUvPackages()).toEqual([
"microsoft-teams-apps==2.0.13.4",
"aiohttp==3.14.3",
+ "google-cloud-pubsub==2.39.0",
+ "google-api-python-client==2.194.0",
+ "google-auth==2.55.1",
]);
});
@@ -149,7 +152,7 @@ describe("managed-image capability union", () => {
NEMOCLAW_MANAGED_IMAGE_CAPABILITY_UNION: "1",
});
expect(fs.readFileSync(trace, "utf8").trim()).toBe(
- "pip install --python /opt/hermes/.venv/bin/python --no-cache -- microsoft-teams-apps==2.0.13.4 aiohttp==3.14.3",
+ "pip install --python /opt/hermes/.venv/bin/python --no-cache -- microsoft-teams-apps==2.0.13.4 aiohttp==3.14.3 google-cloud-pubsub==2.39.0 google-api-python-client==2.194.0 google-auth==2.55.1",
);
} finally {
fs.rmSync(temporaryRoot, { force: true, recursive: true });
diff --git a/test/onboard-pre-destructive-intent.test.ts b/test/onboard-pre-destructive-intent.test.ts
index babe846d955..25104dc0efc 100644
--- a/test/onboard-pre-destructive-intent.test.ts
+++ b/test/onboard-pre-destructive-intent.test.ts
@@ -2,29 +2,34 @@
// SPDX-License-Identifier: Apache-2.0
import assert from "node:assert/strict";
-import { spawnSync } from "node:child_process";
import fs from "node:fs";
-import os from "node:os";
import path from "node:path";
import { describe, it } from "vitest";
+import {
+ createOnboardProcessWorkspace,
+ minimalSpawnEnv,
+ runOnboardProcess,
+ testRepoRoot,
+ trailingJsonPayload,
+} from "./helpers/onboard-child-process-harness";
import { onboardScriptMocksPath } from "./helpers/onboard-split-context";
+const onboardPath = JSON.stringify(path.join(testRepoRoot, "src", "lib", "onboard.ts"));
+const runnerPath = JSON.stringify(path.join(testRepoRoot, "src", "lib", "runner.ts"));
+const registryPath = JSON.stringify(path.join(testRepoRoot, "src", "lib", "state", "registry.ts"));
+const defsPath = JSON.stringify(path.join(testRepoRoot, "src", "lib", "agent", "defs.ts"));
+
describe("onboard sandbox create intent boundary", () => {
- it("rejects stale credential capabilities before real create mutations (#6226)", {
- timeout: 60_000,
- }, () => {
- const repoRoot = path.join(import.meta.dirname, "..");
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-intent-boundary-"));
- try {
- const scriptPath = path.join(tmpDir, "stale-binding.js");
- const onboardPath = JSON.stringify(path.join(repoRoot, "src", "lib", "onboard.ts"));
- const runnerPath = JSON.stringify(path.join(repoRoot, "src", "lib", "runner.ts"));
- const registryPath = JSON.stringify(
- path.join(repoRoot, "src", "lib", "state", "registry.ts"),
- );
-
- const script = String.raw`
+ it(
+ "rejects stale credential capabilities before real create mutations (#6226)",
+ {
+ timeout: 60_000,
+ },
+ () => {
+ const workspace = createOnboardProcessWorkspace("nemoclaw-intent-boundary-");
+ try {
+ const script = String.raw`
const runner = require(${runnerPath});
const registry = require(${registryPath});
const childProcess = require("node:child_process");
@@ -107,39 +112,155 @@ const resolved = {
}
})();
`;
- fs.writeFileSync(scriptPath, script);
-
- const childEnv = Object.fromEntries(
- Object.entries(process.env).filter(([name]) => !/^(?:DISCORD|TELEGRAM)_/.test(name)),
- );
- const result = spawnSync(
- process.execPath,
- ["--require", JSON.parse(onboardScriptMocksPath), scriptPath],
- {
- cwd: repoRoot,
- encoding: "utf-8",
- timeout: 55_000,
- env: {
- ...childEnv,
- HOME: tmpDir,
- NEMOCLAW_NON_INTERACTIVE: "1",
- NEMOCLAW_RECREATE_SANDBOX: "1",
- NEMOCLAW_RECREATE_WITHOUT_BACKUP: "1",
+ const scriptPath = workspace.path("stale-binding.js");
+ fs.writeFileSync(scriptPath, script);
+
+ const result = runOnboardProcess(
+ ["--require", JSON.parse(onboardScriptMocksPath), scriptPath],
+ {
+ env: minimalSpawnEnv(workspace.homeDir, {
+ NEMOCLAW_NON_INTERACTIVE: "1",
+ NEMOCLAW_RECREATE_SANDBOX: "1",
+ NEMOCLAW_RECREATE_WITHOUT_BACKUP: "1",
+ }),
+ timeoutMs: 55_000,
},
- },
- );
-
- assert.equal(result.status, 0, result.stderr);
- const payloadLine = result.stdout
- .trim()
- .split("\n")
- .find((line) => line.startsWith("{") && line.endsWith("}"));
- assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`);
- const payload = JSON.parse(payloadLine);
- assert.match(payload.error, /missing credential binding|credential binding set changed/);
- assert.deepEqual(payload.mutations, []);
- } finally {
- fs.rmSync(tmpDir, { recursive: true, force: true });
- }
- });
+ );
+
+ assert.equal(result.status, 0, result.stderr);
+ const payload = trailingJsonPayload(result.stdout) as {
+ error: string;
+ mutations: string[];
+ };
+ assert.match(payload.error, /missing credential binding|credential binding set changed/);
+ assert.deepEqual(payload.mutations, []);
+ } finally {
+ workspace.remove();
+ }
+ },
+ );
+
+ it(
+ "refuses a selected bridge channel with no usable provider before deleting the sandbox",
+ {
+ timeout: 60_000,
+ },
+ () => {
+ // Onboard offers to delete and recreate a sandbox name under another agent.
+ // The bridge provider name carries no agent, so the gateway still holds the
+ // OpenClaw binding; without the source secret there is nothing to mint from
+ // and nothing safe to reuse. That has to stop the run before the delete.
+ const workspace = createOnboardProcessWorkspace("nemoclaw-bridge-boundary-");
+ try {
+ const script = String.raw`
+const runner = require(${runnerPath});
+const registry = require(${registryPath});
+const defs = require(${defsPath});
+const childProcess = require("node:child_process");
+const record = (text) => { console.log("CMD " + text); return text; };
+
+// The gateway still holds the OpenClaw bridge binding for this sandbox name.
+const staleBinding = [
+ "Name: my-assistant-googlechat-bridge",
+ "Type: google-chat-bridge",
+ "Credential keys: GOOGLE_CHAT_ACCESS_TOKEN",
+ "Config keys: ",
+ "",
+].join(String.fromCharCode(10));
+
+runner.run = (command) => {
+ const text = record(Array.isArray(command) ? command.join(" ") : String(command));
+ if (text.includes("provider get") && text.includes("googlechat-bridge")) {
+ return { status: 0, stdout: staleBinding };
+ }
+ return { status: 0 };
+};
+runner.runCapture = (command) => {
+ const text = record(Array.isArray(command) ? command.join(" ") : String(command));
+ return text.includes("provider get") && text.includes("googlechat-bridge") ? staleBinding : "";
+};
+registry.getSandbox = (name) => ({ name, agent: "openclaw" });
+registry.removeSandbox = (name) => { record("registry remove " + name); };
+registry.updateSandbox = (name) => { record("registry update " + name); };
+registry.registerSandbox = (entry) => { record("registry register " + entry.name); };
+childProcess.spawn = () => { throw new Error("unexpected sandbox create"); };
+
+const { createSandbox } = require(${onboardPath});
+
+(async () => {
+ try {
+ await createSandbox(
+ null,
+ "gpt-5.4",
+ "nvidia-prod",
+ null,
+ "my-assistant",
+ null,
+ ["googlechat"],
+ null,
+ defs.loadAgent("hermes"),
+ null,
+ null,
+ null,
+ [],
+ null,
+ { recreate: true, toolDisclosure: "progressive", observabilityEnabled: false, extraProviders: [] },
+ );
+ console.log("CREATE-RETURNED");
+ } catch (error) {
+ console.log("CREATE-THREW " + String(error.message || error));
+ }
+})();
+`;
+ const scriptPath = workspace.path("stale-bridge.js");
+ fs.writeFileSync(scriptPath, script);
+ // The run reaches the gateway before it reaches the failure under test, and
+ // the binary resolver exits the process when OpenShell is absent.
+ const openshellStub = workspace.writeExecutable("openshell", "#!/bin/sh\nexit 0\n");
+
+ const result = runOnboardProcess(
+ ["--require", JSON.parse(onboardScriptMocksPath), scriptPath],
+ {
+ env: minimalSpawnEnv(workspace.homeDir, {
+ NEMOCLAW_NON_INTERACTIVE: "1",
+ NEMOCLAW_RECREATE_SANDBOX: "1",
+ NEMOCLAW_RECREATE_WITHOUT_BACKUP: "1",
+ NEMOCLAW_OPENSHELL_BIN: openshellStub,
+ }),
+ timeoutMs: 55_000,
+ },
+ );
+
+ const output = result.output;
+ assert.equal(result.error, undefined, output);
+ assert.equal(result.signal, null, output);
+ // The guard exits the process, so neither marker can be reached and the
+ // status is the guard's own. A hang or an unrelated throw fails here.
+ assert.equal(result.status, 1, output);
+ assert.doesNotMatch(output, /CREATE-RETURNED|CREATE-THREW/, output);
+ assert.match(output, /GOOGLECHAT_SERVICE_ACCOUNT/, output);
+
+ // Commands are read from the log the child emits as each one is issued,
+ // since a payload printed at the end would never arrive. The binding read
+ // has to have happened, and nothing may mutate before the refusal.
+ const issued = output
+ .split(String.fromCharCode(10))
+ .filter((line) => line.startsWith("CMD "));
+ assert.equal(
+ issued.filter((line) => /provider get .*my-assistant-googlechat-bridge/.test(line))
+ .length,
+ 1,
+ output,
+ );
+ const mutating = issued.filter((line) =>
+ /sandbox delete|sandbox provider (?:attach|detach)|provider (?:create|update|delete)|registry (?:remove|update|register)/.test(
+ line,
+ ),
+ );
+ assert.deepEqual(mutating, [], output);
+ } finally {
+ workspace.remove();
+ }
+ },
+ );
});
diff --git a/test/sandbox-provider-cleanup.test.ts b/test/sandbox-provider-cleanup.test.ts
index 8be3385bced..21548996ab0 100644
--- a/test/sandbox-provider-cleanup.test.ts
+++ b/test/sandbox-provider-cleanup.test.ts
@@ -39,6 +39,7 @@ describe("SANDBOX_PROVIDER_SUFFIXES", () => {
"slack-bridge",
"slack-app",
"teams-bridge",
+ "googlechat-bridge",
"brave-search",
"tavily-search",
].sort(),